stuff
This commit is contained in:
parent
c19f085e48
commit
deef784b76
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Readability First
|
||||
|
||||
Optimize code for AI agents to understand and modify.
|
||||
|
||||
Never abbreviate. `event` not `e`, `element` not `el`. If it's easy to read, it's correct. In this case, "config" is better than "configuration" because it's shorter and is **still very readable**. "El" is not very readable.
|
||||
|
|
@ -11,4 +11,7 @@ node_modules
|
|||
!*.env.example
|
||||
|
||||
# cursor
|
||||
bun.lockb
|
||||
bun.lockb
|
||||
|
||||
# Twiggy
|
||||
.cursor/rules/file-structure.mdc
|
||||
|
|
|
|||
49
AGENTS.md
49
AGENTS.md
|
|
@ -57,3 +57,52 @@ import { EditorCore } from '@/core';
|
|||
const editor = EditorCore.getInstance();
|
||||
await editor.export({ format: 'mp4', quality: 'high' });
|
||||
```
|
||||
|
||||
## Actions System
|
||||
|
||||
Actions are the trigger layer for user-initiated operations. The single source of truth is `@/lib/actions/definitions.ts`.
|
||||
|
||||
**To add a new action:**
|
||||
|
||||
1. Add it to `ACTIONS` in `@/lib/actions/definitions.ts`:
|
||||
```typescript
|
||||
export const ACTIONS = {
|
||||
"my-action": {
|
||||
description: "What the action does",
|
||||
category: "editing",
|
||||
defaultShortcuts: ["ctrl+m"],
|
||||
},
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
2. Add handler in `@/hooks/use-editor-actions.ts`:
|
||||
```typescript
|
||||
useActionHandler("my-action", () => {
|
||||
// implementation
|
||||
}, undefined);
|
||||
```
|
||||
|
||||
**In components, use `invokeAction()` for user-triggered operations:**
|
||||
|
||||
```typescript
|
||||
import { invokeAction } from '@/lib/actions';
|
||||
|
||||
// Good - uses action system
|
||||
const handleSplit = () => invokeAction("split-selected");
|
||||
|
||||
// Avoid - bypasses UX layer (toasts, validation feedback)
|
||||
const handleSplit = () => editor.timeline.splitElements({ ... });
|
||||
```
|
||||
|
||||
Direct `editor.xxx()` calls are for internal use (commands, tests, complex multi-step operations).
|
||||
|
||||
## Commands System
|
||||
|
||||
Commands handle undo/redo. They live in `@/lib/commands/` organized by domain (timeline, media, scene).
|
||||
|
||||
Each command extends `Command` from `@/lib/commands/base-command` and implements:
|
||||
- `execute()` - saves current state, then does the mutation
|
||||
- `undo()` - restores the saved state
|
||||
|
||||
Actions and commands work together: actions are "what triggered this", commands are "how to do it (and undo it)".
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@
|
|||
"@hookform/resolvers": "^3.9.1",
|
||||
"@opencut/env": "workspace:*",
|
||||
"@opencut/ui": "workspace:*",
|
||||
"@opencut/hooks": "workspace:*",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@upstash/ratelimit": "^2.0.6",
|
||||
"@upstash/redis": "^1.35.4",
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ export function Footer() {
|
|||
<footer className="bg-background border-t">
|
||||
<div className="mx-auto max-w-5xl px-8 py-10">
|
||||
<div className="mb-8 grid grid-cols-1 gap-12 md:grid-cols-2">
|
||||
{/* Brand Section */}
|
||||
<div className="max-w-sm md:col-span-1">
|
||||
<div className="mb-4 flex items-center justify-start gap-2">
|
||||
<Image
|
||||
|
|
@ -81,15 +80,23 @@ export function Footer() {
|
|||
<div className="flex items-start justify-start gap-12 py-2">
|
||||
{(Object.keys(links) as Category[]).map((category) => (
|
||||
<div key={category} className="flex flex-col gap-2">
|
||||
<h3 className="text-foreground font-semibold">{capitalizeFirstLetter({ string: category })}</h3>
|
||||
<h3 className="text-foreground font-semibold">
|
||||
{capitalizeFirstLetter({ string: category })}
|
||||
</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{links[category].map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
target={link.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={link.href.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
target={
|
||||
link.href.startsWith("http") ? "_blank" : undefined
|
||||
}
|
||||
rel={
|
||||
link.href.startsWith("http")
|
||||
? "noopener noreferrer"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
|
|
@ -101,10 +108,11 @@ export function Footer() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Section */}
|
||||
<div className="flex flex-col items-start justify-between gap-4 pt-2 md:flex-row">
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<span>© 2025 OpenCut, All Rights Reserved</span>
|
||||
<span>
|
||||
© {new Date().getFullYear()} OpenCut, All Rights Reserved
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"pages": {}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"pages": {
|
||||
"/_app": []
|
||||
},
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"polyfillFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/development/_ssgManifest.js",
|
||||
"static/development/_buildManifest.js"
|
||||
],
|
||||
"rootMainFiles": [],
|
||||
"ampFirstPages": []
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
{"encryption.key":"qDHbHIqItmhsNnDDb1zJxgaLO9R4RpheEkUFqJlwCAc=","encryption.expire_at":1765569405530}
|
||||
{"encryption.key":"6csmILMKXW5vUdcKm142jEES+lg717F52EbuARHN44o=","encryption.expire_at":1769323998372}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"pages": {
|
||||
"/_app": []
|
||||
},
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"polyfillFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/development/_ssgManifest.js",
|
||||
"static/development/_buildManifest.js"
|
||||
],
|
||||
"rootMainFiles": [],
|
||||
"ampFirstPages": []
|
||||
}
|
||||
|
|
@ -1,3 +1 @@
|
|||
{
|
||||
"type": "commonjs"
|
||||
}
|
||||
{"type": "commonjs"}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"version": 4,
|
||||
"routes": {},
|
||||
"dynamicRoutes": {},
|
||||
"notFoundRoutes": [],
|
||||
"preview": {
|
||||
"previewModeId": "69359808c9a0e2ef1d49645b631e2df3",
|
||||
"previewModeSigningKey": "3633002c1292b21a2ca6234134d5ac8399c49df49cb048069e9f8e2abb74e43f",
|
||||
"previewModeEncryptionKey": "9ba5e3b1ce2abf798e2d8442c165a811960360c5a8cdc5fe7ebbe0df296d2d62"
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"caseSensitive":false,"basePath":"","rewrites":{"beforeFiles":[],"afterFiles":[{"source":"/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/a-4-a/c.js","destination":"https://api.vercel.com/bot-protection/v1/challenge","regex":"^\\/149e9513-01fa-4fb0-aad4-566afd725d1b\\/2d206a39-8ed7-437e-a3be-862e0f06eea3\\/a-4-a\\/c\\.js(?:\\/)?$","check":true},{"source":"/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/:path*","destination":"https://api.vercel.com/bot-protection/v1/proxy/:path*","regex":"^\\/149e9513-01fa-4fb0-aad4-566afd725d1b\\/2d206a39-8ed7-437e-a3be-862e0f06eea3(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))?(?:\\/)?$","check":true}],"fallback":[]},"redirects":[{"source":"/:path+/","destination":"/:path+","permanent":true,"internal":true,"regex":"^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$"}],"headers":[{"source":"/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/:path*","headers":[{"key":"X-Frame-Options","value":"SAMEORIGIN"},{"key":"Content-Security-Policy","value":"frame-ancestors 'self'"}],"regex":"^\\/149e9513-01fa-4fb0-aad4-566afd725d1b\\/2d206a39-8ed7-437e-a3be-862e0f06eea3(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))?(?:\\/)?$"}]}
|
||||
|
|
@ -1 +1,7 @@
|
|||
{}
|
||||
{
|
||||
"/_not-found/page": "app/_not-found/page.js",
|
||||
"/api/sounds/search/route": "app/api/sounds/search/route.js",
|
||||
"/editor/[project_id]/page": "app/editor/[project_id]/page.js",
|
||||
"/page": "app/page.js",
|
||||
"/projects/page": "app/projects/page.js"
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
(globalThis.TURBOPACK || (globalThis.TURBOPACK = [])).push(["chunks/[root-of-the-server]__8bd37c4c._.js",
|
||||
"[externals]/node:buffer [external] (node:buffer, cjs)", ((__turbopack_context__, module, exports) => {
|
||||
|
||||
const mod = __turbopack_context__.x("node:buffer", () => require("node:buffer"));
|
||||
|
||||
module.exports = mod;
|
||||
}),
|
||||
"[externals]/node:async_hooks [external] (node:async_hooks, cjs)", ((__turbopack_context__, module, exports) => {
|
||||
|
||||
const mod = __turbopack_context__.x("node:async_hooks", () => require("node:async_hooks"));
|
||||
|
||||
module.exports = mod;
|
||||
}),
|
||||
"[project]/apps/web/src/middleware.ts [middleware-edge] (ecmascript)", ((__turbopack_context__) => {
|
||||
"use strict";
|
||||
|
||||
__turbopack_context__.s([
|
||||
"config",
|
||||
()=>config,
|
||||
"middleware",
|
||||
()=>middleware
|
||||
]);
|
||||
var __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f2e$bun$2f$next$40$15$2e$5$2e$3$2b$6dbf9a050bc9aadb$2f$node_modules$2f$next$2f$dist$2f$esm$2f$api$2f$server$2e$js__$5b$middleware$2d$edge$5d$__$28$ecmascript$29$__$3c$locals$3e$__ = __turbopack_context__.i("[project]/node_modules/.bun/next@15.5.3+6dbf9a050bc9aadb/node_modules/next/dist/esm/api/server.js [middleware-edge] (ecmascript) <locals>");
|
||||
var __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f2e$bun$2f$next$40$15$2e$5$2e$3$2b$6dbf9a050bc9aadb$2f$node_modules$2f$next$2f$dist$2f$esm$2f$server$2f$web$2f$exports$2f$index$2e$js__$5b$middleware$2d$edge$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[project]/node_modules/.bun/next@15.5.3+6dbf9a050bc9aadb/node_modules/next/dist/esm/server/web/exports/index.js [middleware-edge] (ecmascript)");
|
||||
;
|
||||
async function middleware() {
|
||||
return __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f2e$bun$2f$next$40$15$2e$5$2e$3$2b$6dbf9a050bc9aadb$2f$node_modules$2f$next$2f$dist$2f$esm$2f$server$2f$web$2f$exports$2f$index$2e$js__$5b$middleware$2d$edge$5d$__$28$ecmascript$29$__["NextResponse"].next();
|
||||
}
|
||||
const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - api (API routes)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
*/ "/((?!api|_next/static|_next/image|favicon.ico).*)"
|
||||
]
|
||||
};
|
||||
}),
|
||||
]);
|
||||
|
||||
//# sourceMappingURL=%5Broot-of-the-server%5D__8bd37c4c._.js.map
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sections": [
|
||||
{"offset": {"line": 16, "column": 0}, "map": {"version":3,"sources":["turbopack:///[project]/apps/web/src/middleware.ts"],"sourcesContent":["import { NextResponse } from \"next/server\";\r\n\r\nexport async function middleware() {\r\n return NextResponse.next();\r\n}\r\n\r\nexport const config = {\r\n matcher: [\r\n /*\r\n * Match all request paths except for the ones starting with:\r\n * - api (API routes)\r\n * - _next/static (static files)\r\n * - _next/image (image optimization files)\r\n * - favicon.ico (favicon file)\r\n */\r\n \"/((?!api|_next/static|_next/image|favicon.ico).*)\",\r\n ],\r\n};\r\n"],"names":[],"mappings":";;;;;;AAAA;AAAA;;AAEO,eAAe;IACpB,OAAO,qQAAY,CAAC,IAAI;AAC1B;AAEO,MAAM,SAAS;IACpB,SAAS;QACP;;;;;;KAMC,GACD;KACD;AACH"}}]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
|
|
@ -1 +0,0 @@
|
|||
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]";
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
globalThis.__BUILD_MANIFEST = {
|
||||
"pages": {
|
||||
"/_app": []
|
||||
},
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"polyfillFiles": [],
|
||||
"lowPriorityFiles": [],
|
||||
"rootMainFiles": [],
|
||||
"ampFirstPages": []
|
||||
};
|
||||
globalThis.__BUILD_MANIFEST.lowPriorityFiles = [
|
||||
"/static/" + process.env.__NEXT_BUILD_ID + "/_buildManifest.js",
|
||||
,"/static/" + process.env.__NEXT_BUILD_ID + "/_ssgManifest.js",
|
||||
|
||||
];
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
{
|
||||
"version": 3,
|
||||
"middleware": {
|
||||
"/": {
|
||||
"files": [
|
||||
"server/edge/chunks/_886b33c3._.js",
|
||||
"server/edge/chunks/[root-of-the-server]__8bd37c4c._.js",
|
||||
"server/edge/chunks/turbopack-apps_web_edge-wrapper_53e34dd4.js"
|
||||
],
|
||||
"name": "middleware",
|
||||
"page": "/",
|
||||
"matchers": [
|
||||
{
|
||||
"regexp": "^(?:\\/(_next\\/data\\/[^/]{1,}))?(?:\\/((?!api|_next\\/static|_next\\/image|favicon.ico).*))(\\\\.json)?[\\/#\\?]?$",
|
||||
"originalSource": "/((?!api|_next/static|_next/image|favicon.ico).*)"
|
||||
}
|
||||
],
|
||||
"wasm": [],
|
||||
"assets": [],
|
||||
"env": {
|
||||
"__NEXT_BUILD_ID": "development",
|
||||
"NEXT_SERVER_ACTIONS_ENCRYPTION_KEY": "qDHbHIqItmhsNnDDb1zJxgaLO9R4RpheEkUFqJlwCAc=",
|
||||
"__NEXT_PREVIEW_MODE_ID": "69359808c9a0e2ef1d49645b631e2df3",
|
||||
"__NEXT_PREVIEW_MODE_ENCRYPTION_KEY": "9ba5e3b1ce2abf798e2d8442c165a811960360c5a8cdc5fe7ebbe0df296d2d62",
|
||||
"__NEXT_PREVIEW_MODE_SIGNING_KEY": "3633002c1292b21a2ca6234134d5ac8399c49df49cb048069e9f8e2abb74e43f"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sortedMiddleware": [
|
||||
"/"
|
||||
],
|
||||
"functions": {}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
{
|
||||
"sorted_middleware": [],
|
||||
"middleware": {
|
||||
"/": {
|
||||
"files": [
|
||||
"server/edge/chunks/_886b33c3._.js",
|
||||
"server/edge/chunks/[root-of-the-server]__8bd37c4c._.js",
|
||||
"server/edge/chunks/turbopack-apps_web_edge-wrapper_53e34dd4.js"
|
||||
],
|
||||
"name": "middleware",
|
||||
"page": "/",
|
||||
"matchers": [
|
||||
{
|
||||
"regexp": "/:nextData(_next/data/[^/]{1,})?/((?!api|_next/static|_next/image|favicon.ico).*){(\\\\.json)}?",
|
||||
"originalSource": "/((?!api|_next/static|_next/image|favicon.ico).*)"
|
||||
}
|
||||
],
|
||||
"wasm": [],
|
||||
"assets": [],
|
||||
"env": {
|
||||
"__NEXT_BUILD_ID": "development",
|
||||
"NEXT_SERVER_ACTIONS_ENCRYPTION_KEY": "qDHbHIqItmhsNnDDb1zJxgaLO9R4RpheEkUFqJlwCAc=",
|
||||
"__NEXT_PREVIEW_MODE_ID": "69359808c9a0e2ef1d49645b631e2df3",
|
||||
"__NEXT_PREVIEW_MODE_ENCRYPTION_KEY": "9ba5e3b1ce2abf798e2d8442c165a811960360c5a8cdc5fe7ebbe0df296d2d62",
|
||||
"__NEXT_PREVIEW_MODE_SIGNING_KEY": "3633002c1292b21a2ca6234134d5ac8399c49df49cb048069e9f8e2abb74e43f"
|
||||
}
|
||||
}
|
||||
},
|
||||
"instrumentation": null,
|
||||
"functions": {}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
self.__NEXT_FONT_MANIFEST="{\n \"app\": {},\n \"appUsingSizeAdjust\": false,\n \"pages\": {},\n \"pagesUsingSizeAdjust\": false\n}"
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"app": {},
|
||||
"appUsingSizeAdjust": false,
|
||||
"pages": {},
|
||||
"pagesUsingSizeAdjust": false
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"qDHbHIqItmhsNnDDb1zJxgaLO9R4RpheEkUFqJlwCAc=\"\n}"
|
||||
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"6csmILMKXW5vUdcKm142jEES+lg717F52EbuARHN44o=\"\n}"
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "qDHbHIqItmhsNnDDb1zJxgaLO9R4RpheEkUFqJlwCAc="
|
||||
"encryptionKey": "6csmILMKXW5vUdcKm142jEES+lg717F52EbuARHN44o="
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
self.__BUILD_MANIFEST = {
|
||||
"__rewrites": {
|
||||
"afterFiles": [
|
||||
{
|
||||
"source": "/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/a-4-a/c.js"
|
||||
},
|
||||
{
|
||||
"source": "/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/:path*"
|
||||
}
|
||||
],
|
||||
"beforeFiles": [],
|
||||
"fallback": []
|
||||
},
|
||||
"sortedPages": [
|
||||
"/_app",
|
||||
"/_error"
|
||||
]
|
||||
};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
[
|
||||
{
|
||||
"regexp": "^(?:\\/(_next\\/data\\/[^/]{1,}))?(?:\\/((?!api|_next\\/static|_next\\/image|favicon.ico).*))(\\\\.json)?[\\/#\\?]?$",
|
||||
"originalSource": "/((?!api|_next/static|_next/image|favicon.ico).*)"
|
||||
}
|
||||
]
|
||||
|
|
@ -1 +0,0 @@
|
|||
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -23,6 +23,7 @@
|
|||
"@hookform/resolvers": "^3.9.1",
|
||||
"@opencut/env": "workspace:*",
|
||||
"@opencut/ui": "workspace:*",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@upstash/ratelimit": "^2.0.6",
|
||||
"@upstash/redis": "^1.35.4",
|
||||
|
|
@ -35,22 +36,21 @@
|
|||
"cmdk": "^1.0.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"postgres": "^3.4.5",
|
||||
"embla-carousel-react": "^8.5.1",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"feed": "^5.1.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"mediabunny": "^1.9.3",
|
||||
"motion": "^12.18.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "15.5.7",
|
||||
"next-themes": "^0.4.4",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.2",
|
||||
"mediabunny": "^1.9.3",
|
||||
"react-country-flag": "^3.1.0",
|
||||
"wavesurfer.js": "^7.9.8",
|
||||
"react": "^18.2.0",
|
||||
"react-country-flag": "^3.1.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.54.0",
|
||||
|
|
@ -70,6 +70,7 @@
|
|||
"unified": "^11.0.5",
|
||||
"use-deep-compare-effect": "^1.8.1",
|
||||
"vaul": "^1.1.1",
|
||||
"wavesurfer.js": "^7.9.8",
|
||||
"zod": "^3.25.67",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { auth } from "@opencut/auth";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||
import { z } from "zod";
|
||||
import { AwsClient } from "aws4fetch";
|
||||
import { nanoid } from "nanoid";
|
||||
import { env } from "@/env";
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { isTranscriptionConfigured } from "@/lib/transcription-utils";
|
||||
|
||||
|
|
@ -64,15 +64,15 @@ export async function POST(request: NextRequest) {
|
|||
const { fileExtension } = validationResult.data;
|
||||
|
||||
const client = new AwsClient({
|
||||
accessKeyId: env.R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
|
||||
accessKeyId: webEnv.R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: webEnv.R2_SECRET_ACCESS_KEY,
|
||||
});
|
||||
|
||||
const timestamp = Date.now();
|
||||
const fileName = `audio/${timestamp}-${nanoid()}.${fileExtension}`;
|
||||
|
||||
const url = new URL(
|
||||
`https://${env.R2_BUCKET_NAME}.${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com/${fileName}`
|
||||
`https://${webEnv.R2_BUCKET_NAME}.${webEnv.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com/${fileName}`
|
||||
);
|
||||
|
||||
url.searchParams.set("X-Amz-Expires", "3600"); // 1 hour expiry
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { env } from "@/env";
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
|
|
@ -200,7 +200,7 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
const params = new URLSearchParams({
|
||||
query: query || "",
|
||||
token: env.FREESOUND_API_KEY,
|
||||
token: webEnv.FREESOUND_API_KEY,
|
||||
page: page.toString(),
|
||||
page_size: pageSize.toString(),
|
||||
sort: sortParam,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { env } from "@/env";
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { isTranscriptionConfigured } from "@/lib/transcription-utils";
|
||||
|
||||
|
|
@ -128,7 +128,7 @@ export async function POST(request: NextRequest) {
|
|||
iv
|
||||
});
|
||||
|
||||
const response = await fetch(env.MODAL_TRANSCRIPTION_URL, {
|
||||
const response = await fetch(webEnv.MODAL_TRANSCRIPTION_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
|
|||
|
|
@ -14,22 +14,21 @@ import { EditorHeader } from "@/components/editor/editor-header";
|
|||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||
import { Onboarding } from "@/components/editor/onboarding";
|
||||
import { useProjectInitialization } from "@/hooks/use-project-initialization";
|
||||
import { MigrationDialog } from "@/components/editor/migration-dialog";
|
||||
|
||||
export default function Editor() {
|
||||
const params = useParams();
|
||||
const projectId = params.project_id as string;
|
||||
|
||||
useProjectInitialization({ projectId });
|
||||
|
||||
return (
|
||||
<EditorProvider>
|
||||
<EditorProvider projectId={projectId}>
|
||||
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
|
||||
<EditorHeader />
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<EditorLayout />
|
||||
</div>
|
||||
<Onboarding />
|
||||
<MigrationDialog />
|
||||
</div>
|
||||
</EditorProvider>
|
||||
);
|
||||
|
|
@ -48,7 +47,7 @@ function EditorLayout({}: {}) {
|
|||
setMainContent,
|
||||
setTimeline,
|
||||
propertiesPanel,
|
||||
setPropertiesPanel,
|
||||
setPropertiesPanel,
|
||||
} = usePanelStore();
|
||||
|
||||
return activePreset === "media" ? (
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import { ThemeProvider } from "next-themes";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import Script from "next/script";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { TooltipProvider } from "../components/ui/tooltip";
|
||||
import { StorageProvider } from "../components/storage-provider";
|
||||
import { ScenesMigrator } from "../components/providers/migrators/scenes-migrator";
|
||||
import { baseMetaData } from "./metadata";
|
||||
import { BotIdClient } from "botid/client";
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
|
|
@ -33,12 +30,12 @@ export default function RootLayout({
|
|||
<BotIdClient protect={protectedRoutes} />
|
||||
</head>
|
||||
<body className={`${siteFont.className} font-sans antialiased`}>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark">
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
disableTransitionOnChange={true}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<StorageProvider>
|
||||
<ScenesMigrator>{children}</ScenesMigrator>
|
||||
</StorageProvider>
|
||||
<Analytics />
|
||||
<Toaster />
|
||||
<Script
|
||||
src="https://cdn.databuddy.cc/databuddy.js"
|
||||
|
|
@ -52,6 +49,7 @@ export default function RootLayout({
|
|||
data-track-web-vitals={false}
|
||||
data-track-sessions={false}
|
||||
/>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import {
|
||||
Calendar,
|
||||
ChevronLeft,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
ArrowDown01,
|
||||
Plus,
|
||||
|
|
@ -15,7 +14,8 @@ import {
|
|||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { KeyboardEvent, MouseEvent } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { DeleteProjectDialog } from "@/components/delete-project-dialog";
|
||||
import { RenameProjectDialog } from "@/components/rename-project-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -36,10 +36,9 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { EditorCore } from "@/core";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import type { TProject } from "@/types/project";
|
||||
import type { TProjectMetadata } from "@/types/project";
|
||||
import { toast } from "sonner";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
|
|
@ -50,7 +49,9 @@ export default function ProjectsPage() {
|
|||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortOption, setSortOption] = useState("createdAt-desc");
|
||||
const router = useRouter();
|
||||
const editor = EditorCore.getInstance();
|
||||
const editor = useEditor();
|
||||
|
||||
const hasSearchQuery = searchQuery.trim().length > 0;
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
try {
|
||||
|
|
@ -66,7 +67,26 @@ export default function ProjectsPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleSelectProject = (projectId: string, checked: boolean) => {
|
||||
const toggleSortOption = ({
|
||||
sortField,
|
||||
}: {
|
||||
sortField: "createdAt" | "name";
|
||||
}) => {
|
||||
const isSameField = sortOption.startsWith(sortField);
|
||||
const nextSortOption = isSameField
|
||||
? `${sortField}-${sortOption.endsWith("asc") ? "desc" : "asc"}`
|
||||
: `${sortField}-asc`;
|
||||
|
||||
setSortOption(nextSortOption);
|
||||
};
|
||||
|
||||
const handleSelectProject = ({
|
||||
projectId,
|
||||
checked,
|
||||
}: {
|
||||
projectId: string;
|
||||
checked: boolean;
|
||||
}) => {
|
||||
const newSelected = new Set(selectedProjects);
|
||||
if (checked) {
|
||||
newSelected.add(projectId);
|
||||
|
|
@ -76,9 +96,9 @@ export default function ProjectsPage() {
|
|||
setSelectedProjects(newSelected);
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
const handleSelectAll = ({ checked }: { checked: boolean }) => {
|
||||
if (checked) {
|
||||
setSelectedProjects(new Set(sortedProjects.map((p) => p.id)));
|
||||
setSelectedProjects(new Set(sortedProjects.map((project) => project.id)));
|
||||
} else {
|
||||
setSelectedProjects(new Set());
|
||||
}
|
||||
|
|
@ -113,15 +133,18 @@ export default function ProjectsPage() {
|
|||
sortOption,
|
||||
});
|
||||
|
||||
const allSelected =
|
||||
const isAllSelected =
|
||||
sortedProjects.length > 0 &&
|
||||
selectedProjects.size === sortedProjects.length;
|
||||
const someSelected =
|
||||
const hasSomeSelected =
|
||||
selectedProjects.size > 0 && selectedProjects.size < sortedProjects.length;
|
||||
|
||||
const isLoading = editor.project.getIsLoading();
|
||||
const isInitialized = editor.project.getIsInitialized();
|
||||
|
||||
return (
|
||||
<div className="bg-background min-h-screen">
|
||||
<div className="flex h-16 w-full items-center justify-between px-6 pt-6">
|
||||
<div className="flex h-16 w-full items-center justify-between px-6 pt-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="hover:text-muted-foreground flex items-center gap-1 transition-colors"
|
||||
|
|
@ -160,7 +183,7 @@ export default function ProjectsPage() {
|
|||
<div className="mb-8 flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">
|
||||
Your Projects
|
||||
Your projects
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{sortedProjects.length}{" "}
|
||||
|
|
@ -185,7 +208,7 @@ export default function ProjectsPage() {
|
|||
onClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="size-4!" />
|
||||
Delete Selected ({selectedProjects.size})
|
||||
Delete selected ({selectedProjects.size})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -209,7 +232,7 @@ export default function ProjectsPage() {
|
|||
<Input
|
||||
placeholder="Search projects..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-0">
|
||||
|
|
@ -219,47 +242,31 @@ export default function ProjectsPage() {
|
|||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="sort projects"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="h-9 w-9 items-center justify-center"
|
||||
variant="outline"
|
||||
className="size-9 items-center justify-center"
|
||||
>
|
||||
<ArrowDown01
|
||||
strokeWidth={1.5}
|
||||
className="!size-[1.05rem]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortOption.startsWith("createdAt")) {
|
||||
setSortOption(
|
||||
sortOption.endsWith("asc")
|
||||
? "createdAt-desc"
|
||||
: "createdAt-asc",
|
||||
);
|
||||
} else {
|
||||
setSortOption("createdAt-asc");
|
||||
}
|
||||
}}
|
||||
onClick={() =>
|
||||
toggleSortOption({ sortField: "createdAt" })
|
||||
}
|
||||
>
|
||||
Created{" "}
|
||||
{sortOption.startsWith("createdAt") &&
|
||||
(sortOption.endsWith("asc") ? "↑" : "↓")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortOption.startsWith("name")) {
|
||||
setSortOption(
|
||||
sortOption.endsWith("asc")
|
||||
? "name-desc"
|
||||
: "name-asc",
|
||||
);
|
||||
} else {
|
||||
setSortOption("name-asc");
|
||||
}
|
||||
}}
|
||||
onClick={() => toggleSortOption({ sortField: "name" })}
|
||||
>
|
||||
Name{" "}
|
||||
{sortOption.startsWith("name") &&
|
||||
|
|
@ -282,19 +289,21 @@ export default function ProjectsPage() {
|
|||
{isSelectionMode && sortedProjects.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectAll(!allSelected)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelectAll(!allSelected);
|
||||
onClick={() => handleSelectAll({ checked: !isAllSelected })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleSelectAll({ checked: !isAllSelected });
|
||||
}
|
||||
}}
|
||||
className="bg-muted/30 mb-6 flex w-full items-center gap-2 rounded-lg border p-4 hover:cursor-pointer"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Checkbox checked={someSelected ? "indeterminate" : allSelected} />
|
||||
<Checkbox
|
||||
checked={hasSomeSelected ? "indeterminate" : isAllSelected}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{allSelected ? "Deselect All" : "Select All"}
|
||||
{isAllSelected ? "Deselect all" : "Select all"}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
({selectedProjects.size} of {sortedProjects.length} selected)
|
||||
|
|
@ -302,15 +311,15 @@ export default function ProjectsPage() {
|
|||
</button>
|
||||
)}
|
||||
|
||||
{editor.project.isLoading || !editor.project.isInitialized ? (
|
||||
{isLoading || !isInitialized ? (
|
||||
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 8 }, (_, index) => (
|
||||
<div
|
||||
key={`skeleton-${index}-${Date.now()}`}
|
||||
key={`skeleton-${index}`}
|
||||
className="bg-background overflow-hidden border-none p-0"
|
||||
>
|
||||
<Skeleton className="bg-muted/50 aspect-square w-full" />
|
||||
<div className="flex flex-col gap-1 px-0 pt-5">
|
||||
<div className="flex flex-col gap-1.5 px-0 pt-5">
|
||||
<Skeleton className="bg-muted/50 h-4 w-3/4" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="bg-muted/50 h-4 w-4" />
|
||||
|
|
@ -320,13 +329,13 @@ export default function ProjectsPage() {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : sortedProjects.length === 0 ? (
|
||||
<NoProjects onCreateProject={handleCreateProject} />
|
||||
) : sortedProjects.length === 0 ? (
|
||||
) : sortedProjects.length === 0 && hasSearchQuery ? (
|
||||
<NoResults
|
||||
searchQuery={searchQuery}
|
||||
onClearSearch={() => setSearchQuery("")}
|
||||
/>
|
||||
) : sortedProjects.length === 0 ? (
|
||||
<NoProjects onCreateProject={handleCreateProject} />
|
||||
) : (
|
||||
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{sortedProjects.map((project) => (
|
||||
|
|
@ -352,10 +361,16 @@ export default function ProjectsPage() {
|
|||
}
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: TProject;
|
||||
project: TProjectMetadata;
|
||||
isSelectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onSelect?: (projectId: string, checked: boolean) => void;
|
||||
onSelect?: ({
|
||||
projectId,
|
||||
checked,
|
||||
}: {
|
||||
projectId: string;
|
||||
checked: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function ProjectCard({
|
||||
|
|
@ -367,9 +382,9 @@ function ProjectCard({
|
|||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const { deleteProject, renameProject, duplicateProject } = useProjectStore();
|
||||
const editor = useEditor();
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
const formatDate = ({ date }: { date: Date }): string => {
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
|
|
@ -378,31 +393,34 @@ function ProjectCard({
|
|||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
await deleteProject(project.id);
|
||||
await editor.project.deleteProject({ id: project.id });
|
||||
setIsDropdownOpen(false);
|
||||
};
|
||||
|
||||
const handleRenameProject = async (newName: string) => {
|
||||
await renameProject(project.id, newName);
|
||||
const handleRenameProject = async ({ name }: { name: string }) => {
|
||||
await editor.project.renameProject({ id: project.id, name });
|
||||
setIsRenameDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleDuplicateProject = async () => {
|
||||
setIsDropdownOpen(false);
|
||||
await duplicateProject(project.id);
|
||||
await editor.project.duplicateProject({ id: project.id });
|
||||
};
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
const handleCardClick = ({e}: {}) => {
|
||||
if (isSelectionMode) {
|
||||
e.preventDefault();
|
||||
onSelect?.(project.id, !isSelected);
|
||||
onSelect?.({ projectId: project.id, checked: !isSelected });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (isSelectionMode && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
onSelect?.(project.id, !isSelected);
|
||||
const handleCardKeyDown = ({
|
||||
key,
|
||||
preventDefault,
|
||||
}: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (isSelectionMode && (key === "Enter" || key === " ")) {
|
||||
preventDefault();
|
||||
onSelect?.({ projectId: project.id, checked: !isSelected });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -419,14 +437,17 @@ function ProjectCard({
|
|||
>
|
||||
{isSelectionMode && (
|
||||
<div className="absolute left-3 top-3 z-10">
|
||||
<div className="bg-background/80 backdrop-blur-xs flex h-5 w-5 items-center justify-center rounded-full border">
|
||||
<div className="bg-background/80 backdrop-blur-xs flex size-5 items-center justify-center rounded-full border">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) =>
|
||||
onSelect?.(project.id, checked as boolean)
|
||||
onSelect?.({
|
||||
projectId: project.id,
|
||||
checked: checked === true,
|
||||
})
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="size-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -442,7 +463,7 @@ function ProjectCard({
|
|||
/>
|
||||
) : (
|
||||
<div className="bg-muted/50 flex h-full w-full items-center justify-center">
|
||||
<Video className="text-muted-foreground h-12 w-12 shrink-0" />
|
||||
<Video className="text-muted-foreground size-12 shrink-0" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -460,6 +481,7 @@ function ProjectCard({
|
|||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="project options"
|
||||
variant="text"
|
||||
size="sm"
|
||||
className={`ml-2 size-6 shrink-0 p-0 transition-all ${
|
||||
|
|
@ -467,22 +489,22 @@ function ProjectCard({
|
|||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
onClick={(event) => event.preventDefault()}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
<MoreHorizontal aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDropdownOpen(false);
|
||||
setIsRenameDialogOpen(true);
|
||||
}}
|
||||
|
|
@ -490,9 +512,9 @@ function ProjectCard({
|
|||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleDuplicateProject();
|
||||
}}
|
||||
>
|
||||
|
|
@ -501,9 +523,9 @@ function ProjectCard({
|
|||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDropdownOpen(false);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}
|
||||
|
|
@ -518,7 +540,7 @@ function ProjectCard({
|
|||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-sm">
|
||||
<Calendar className="size-4!" />
|
||||
<span>Created {formatDate(project.createdAt)}</span>
|
||||
<span>Created {formatDate({ date: project.createdAt })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -549,7 +571,7 @@ function ProjectCard({
|
|||
<RenameProjectDialog
|
||||
isOpen={isRenameDialogOpen}
|
||||
onOpenChange={setIsRenameDialogOpen}
|
||||
onConfirm={handleRenameProject}
|
||||
onConfirm={(name) => handleRenameProject({ name })}
|
||||
projectName={project.name}
|
||||
/>
|
||||
</>
|
||||
|
|
@ -567,18 +589,20 @@ function CreateButton({ onClick }: { onClick?: () => void }) {
|
|||
|
||||
function NoProjects({ onCreateProject }: { onCreateProject: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="bg-muted/30 mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<Video className="text-muted-foreground h-8 w-8" />
|
||||
<div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="bg-muted/30 flex size-16 items-center justify-center rounded-full">
|
||||
<Video className="text-muted-foreground size-8" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">No projects yet</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
Start creating your first video project. Import media, edit, and
|
||||
export professional videos.
|
||||
</p>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-medium">No projects yet</h3>
|
||||
<p className="text-muted-foreground mb-6 max-w-md">
|
||||
Start creating your first video project. Import media, edit, and export
|
||||
professional videos.
|
||||
</p>
|
||||
<Button size="lg" className="gap-2" onClick={onCreateProject}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Your First Project
|
||||
Create your first project
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -592,16 +616,18 @@ function NoResults({
|
|||
onClearSearch: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="bg-muted/30 mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<Search className="text-muted-foreground h-8 w-8" />
|
||||
<div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="bg-muted/30 flex size-16 items-center justify-center rounded-full">
|
||||
<Search className="text-muted-foreground size-8" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">No results found</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
Your search for "{searchQuery}" did not return any results.
|
||||
</p>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-medium">No results found</h3>
|
||||
<p className="text-muted-foreground mb-6 max-w-md">
|
||||
Your search for "{searchQuery}" did not return any results.
|
||||
</p>
|
||||
<Button onClick={onClearSearch} variant="outline">
|
||||
Clear Search
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
import { Metadata } from "next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { GithubIcon } from "@/components/icons";
|
||||
import Link from "next/link";
|
||||
import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
|
||||
|
||||
type StatusType = "complete" | "pending" | "default" | "info";
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { PropertyGroup } from "../../properties-panel/property-item";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
|
||||
import { Language, LanguageSelect } from "@/components/language-select";
|
||||
import { LanguageSelect } from "@/components/language-select";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { extractTimelineAudio } from "@/lib/mediabunny-utils";
|
||||
import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
||||
import { LANGUAGES } from "@/constants/captions-constants";
|
||||
import { Loader2, Shield, Trash2, Upload } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -18,17 +19,11 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { TextElement } from "@/types/timeline";
|
||||
|
||||
export const languages: Language[] = [
|
||||
{ code: "US", 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: "JP", name: "Japanese" },
|
||||
{ code: "CN", name: "Chinese" },
|
||||
];
|
||||
interface TranscriptionSegment {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const PRIVACY_DIALOG_KEY = "opencut-transcription-privacy-accepted";
|
||||
|
||||
|
|
@ -40,9 +35,8 @@ export function Captions() {
|
|||
const [showPrivacyDialog, setShowPrivacyDialog] = useState(false);
|
||||
const [hasAcceptedPrivacy, setHasAcceptedPrivacy] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { insertTrackAt, addElementToTrack } = useTimelineStore();
|
||||
const editor = useEditor();
|
||||
|
||||
// Check if user has already accepted privacy on mount
|
||||
useEffect(() => {
|
||||
const hasAccepted = localStorage.getItem(PRIVACY_DIALOG_KEY) === "true";
|
||||
setHasAcceptedPrivacy(hasAccepted);
|
||||
|
|
@ -57,12 +51,8 @@ export function Captions() {
|
|||
const audioBlob = await extractTimelineAudio();
|
||||
|
||||
setProcessingStep("Encrypting audio...");
|
||||
|
||||
// Encrypt the audio with a random key (zero-knowledge)
|
||||
const audioBuffer = await audioBlob.arrayBuffer();
|
||||
const encryptionResult = await encryptWithRandomKey(audioBuffer);
|
||||
|
||||
// Convert encrypted data to blob for upload
|
||||
const encryptedBlob = new Blob([encryptionResult.encryptedData]);
|
||||
|
||||
setProcessingStep("Uploading...");
|
||||
|
|
@ -79,7 +69,6 @@ export function Captions() {
|
|||
|
||||
const { uploadUrl, fileName } = await uploadResponse.json();
|
||||
|
||||
// Upload to R2
|
||||
await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
body: encryptedBlob,
|
||||
|
|
@ -87,7 +76,6 @@ export function Captions() {
|
|||
|
||||
setProcessingStep("Transcribing...");
|
||||
|
||||
// Call Modal transcription API with encryption parameters
|
||||
const transcriptionResponse = await fetch("/api/transcribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -95,7 +83,6 @@ export function Captions() {
|
|||
filename: fileName,
|
||||
language:
|
||||
selectedCountry === "auto" ? "auto" : selectedCountry.toLowerCase(),
|
||||
// Send the raw encryption key and IV (zero-knowledge)
|
||||
decryptionKey: arrayBufferToBase64(encryptionResult.key),
|
||||
iv: arrayBufferToBase64(encryptionResult.iv),
|
||||
}),
|
||||
|
|
@ -116,32 +103,23 @@ export function Captions() {
|
|||
duration: number;
|
||||
}> = [];
|
||||
|
||||
let globalEndTime = 0; // Track the end time of the last caption globally
|
||||
let globalEndTime = 0;
|
||||
|
||||
segments.forEach((segment: any) => {
|
||||
segments.forEach((segment: TranscriptionSegment) => {
|
||||
const words = segment.text.trim().split(/\s+/);
|
||||
const segmentDuration = segment.end - segment.start;
|
||||
const wordsPerSecond = words.length / segmentDuration;
|
||||
|
||||
// Split into chunks of 2-4 words
|
||||
const chunks: string[] = [];
|
||||
for (let i = 0; i < words.length; i += 3) {
|
||||
chunks.push(words.slice(i, i + 3).join(" "));
|
||||
}
|
||||
|
||||
// Calculate timing for each chunk to place them sequentially
|
||||
let chunkStartTime = segment.start;
|
||||
chunks.forEach((chunk) => {
|
||||
const chunkWords = chunk.split(/\s+/).length;
|
||||
const chunkDuration = Math.max(0.8, chunkWords / wordsPerSecond); // Minimum 0.8s per chunk
|
||||
|
||||
let adjustedStartTime = chunkStartTime;
|
||||
|
||||
// Prevent overlapping: if this caption would start before the last one ends,
|
||||
// start it right after the last one ends
|
||||
if (adjustedStartTime < globalEndTime) {
|
||||
adjustedStartTime = globalEndTime;
|
||||
}
|
||||
const chunkDuration = Math.max(0.8, chunkWords / wordsPerSecond);
|
||||
const adjustedStartTime = Math.max(chunkStartTime, globalEndTime);
|
||||
|
||||
shortCaptions.push({
|
||||
text: chunk,
|
||||
|
|
@ -149,28 +127,26 @@ export function Captions() {
|
|||
duration: chunkDuration,
|
||||
});
|
||||
|
||||
// Update global end time
|
||||
globalEndTime = adjustedStartTime + chunkDuration;
|
||||
|
||||
// Next chunk starts when this one ends (for within-segment timing)
|
||||
chunkStartTime += chunkDuration;
|
||||
});
|
||||
});
|
||||
|
||||
// Create a single track for all captions
|
||||
const captionTrackId = insertTrackAt("text", 0);
|
||||
const captionTrackId = editor.timeline.addTrack({ type: "text", index: 0 });
|
||||
|
||||
// Add all caption elements to the same track
|
||||
shortCaptions.forEach((caption, index) => {
|
||||
addElementToTrack(captionTrackId, {
|
||||
...DEFAULT_TEXT_ELEMENT,
|
||||
name: `Caption ${index + 1}`,
|
||||
content: caption.text,
|
||||
duration: caption.duration,
|
||||
startTime: caption.startTime,
|
||||
fontSize: 65, // Larger for captions
|
||||
fontWeight: "bold", // Bold for captions
|
||||
} as TextElement);
|
||||
editor.timeline.addElementToTrack({
|
||||
trackId: captionTrackId,
|
||||
element: {
|
||||
...DEFAULT_TEXT_ELEMENT,
|
||||
name: `Caption ${index + 1}`,
|
||||
content: caption.text,
|
||||
duration: caption.duration,
|
||||
startTime: caption.startTime,
|
||||
fontSize: 65,
|
||||
fontWeight: "bold",
|
||||
} as TextElement,
|
||||
});
|
||||
});
|
||||
|
||||
console.log(
|
||||
|
|
@ -194,7 +170,7 @@ export function Captions() {
|
|||
selectedCountry={selectedCountry}
|
||||
onSelect={setSelectedCountry}
|
||||
containerRef={containerRef}
|
||||
languages={languages}
|
||||
languages={LANGUAGES}
|
||||
/>
|
||||
</PropertyGroup>
|
||||
|
||||
|
|
@ -216,7 +192,7 @@ export function Captions() {
|
|||
}}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing && <Loader2 className="mr-1 h-4 w-4 animate-spin" />}
|
||||
{isProcessing && <Loader2 className="mr-1 size-4 animate-spin" />}
|
||||
{isProcessing ? processingStep : "Generate transcript"}
|
||||
</Button>
|
||||
|
||||
|
|
@ -224,8 +200,8 @@ export function Captions() {
|
|||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Audio Processing Notice
|
||||
<Shield className="size-5" />
|
||||
Audio processing notice
|
||||
</DialogTitle>
|
||||
<DialogDescription className="space-y-3">
|
||||
<p>
|
||||
|
|
@ -235,7 +211,7 @@ export function Captions() {
|
|||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield className="h-4 w-4 flex-shrink-0" />
|
||||
<Shield className="size-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Zero-knowledge encryption - we cannot decrypt your files
|
||||
even if we wanted to
|
||||
|
|
@ -243,7 +219,7 @@ export function Captions() {
|
|||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield className="h-4 w-4 flex-shrink-0" />
|
||||
<Shield className="size-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Encryption keys generated randomly in your browser, never
|
||||
stored anywhere
|
||||
|
|
@ -251,7 +227,7 @@ export function Captions() {
|
|||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Upload className="h-4 w-4 flex-shrink-0" />
|
||||
<Upload className="size-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Audio encrypted before upload - raw audio never leaves
|
||||
your device
|
||||
|
|
@ -259,7 +235,7 @@ export function Captions() {
|
|||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Trash2 className="h-4 w-4 flex-shrink-0" />
|
||||
<Trash2 className="size-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Everything permanently deleted within seconds after
|
||||
transcription
|
||||
|
|
@ -292,7 +268,7 @@ export function Captions() {
|
|||
}}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Continue & Generate Captions
|
||||
Continue & generate captions
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useFileUpload } from "@/hooks/use-file-upload";
|
||||
import { processMediaFiles } from "@/lib/media-processing-utils";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { MediaFile } from "@/types/assets";
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
ArrowDown01,
|
||||
CloudUpload,
|
||||
|
|
@ -14,9 +11,16 @@ import {
|
|||
Music,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useRevealItem } from "@/hooks/use-reveal-item";
|
||||
import { toast } from "sonner";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useFileUpload } from "@/hooks/use-file-upload";
|
||||
import { useRevealItem } from "@/hooks/use-reveal-item";
|
||||
import { processMediaAssets } from "@/lib/media-processing-utils";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { canElementGoOnTrack } from "@/lib/timeline/track-utils";
|
||||
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
import type { CreateTimelineElement, TrackType } from "@/types/timeline";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MediaDragOverlay } from "@/components/editor/assets-panel/drag-overlay";
|
||||
import {
|
||||
|
|
@ -31,9 +35,7 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { DraggableItem } from "@/components/ui/draggable-item";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
|
|
@ -41,50 +43,28 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useAssetsPanelStore } from "../../../../stores/assets-panel-store";
|
||||
|
||||
function MediaItemWithContextMenu({
|
||||
item,
|
||||
children,
|
||||
onRemove,
|
||||
}: {
|
||||
item: MediaFile;
|
||||
children: React.ReactNode;
|
||||
onRemove: (e: React.MouseEvent, id: string) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Export clips</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={(e) => onRemove(e, item.id)}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||
|
||||
export function MediaView() {
|
||||
const { mediaFiles, addMediaFile, removeMediaFile } = useMediaStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const editor = useEditor();
|
||||
const mediaFiles = editor.media.getAssets();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const { mediaViewMode, setMediaViewMode } = usePanelStore();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
|
||||
"name",
|
||||
);
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const { highlightMediaId, clearHighlight } = useAssetsPanelStore();
|
||||
const { highlightedId, registerElement } = useRevealItem(
|
||||
highlightMediaId,
|
||||
clearHighlight,
|
||||
);
|
||||
|
||||
const processFiles = async (files: FileList | File[]) => {
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
|
||||
"name",
|
||||
);
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
|
||||
const processFiles = async ({ files }: { files: FileList }) => {
|
||||
if (!files || files.length === 0) return;
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
|
|
@ -94,12 +74,16 @@ export function MediaView() {
|
|||
setIsProcessing(true);
|
||||
setProgress(0);
|
||||
try {
|
||||
const processedItems = await processMediaFiles({
|
||||
files: files as FileList,
|
||||
onProgress: (p: { progress: number }) => setProgress(p.progress),
|
||||
const processedAssets = await processMediaAssets({
|
||||
files,
|
||||
onProgress: (progress: { progress: number }) =>
|
||||
setProgress(progress.progress),
|
||||
});
|
||||
for (const item of processedItems) {
|
||||
await addMediaFile(activeProject.id, item);
|
||||
for (const asset of processedAssets) {
|
||||
await editor.media.addMediaAsset({
|
||||
projectId: activeProject.metadata.id,
|
||||
asset,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing files:", error);
|
||||
|
|
@ -114,32 +98,76 @@ export function MediaView() {
|
|||
useFileUpload({
|
||||
accept: "image/*,video/*,audio/*",
|
||||
multiple: true,
|
||||
onFilesSelected: processFiles,
|
||||
onFilesSelected: (files) => processFiles({ files }),
|
||||
});
|
||||
|
||||
const handleRemove = async (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
const handleRemove = async ({
|
||||
event,
|
||||
id,
|
||||
}: {
|
||||
event: React.MouseEvent;
|
||||
id: string;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return;
|
||||
}
|
||||
|
||||
await removeMediaFile(activeProject.id, id);
|
||||
await editor.media.removeMediaAsset({
|
||||
projectId: activeProject.metadata.id,
|
||||
id,
|
||||
});
|
||||
};
|
||||
|
||||
const formatDuration = (duration: number) => {
|
||||
// Format seconds as mm:ss
|
||||
const min = Math.floor(duration / 60);
|
||||
const sec = Math.floor(duration % 60);
|
||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
||||
const addElementAtTime = ({
|
||||
asset,
|
||||
startTime,
|
||||
}: {
|
||||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
}): boolean => {
|
||||
const element = createElementFromMedia({ asset, startTime });
|
||||
const trackType = getTrackTypeForMedia({ mediaType: asset.type });
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const duration =
|
||||
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
|
||||
const existingTrack = tracks.find((track) => {
|
||||
if (
|
||||
!canElementGoOnTrack({
|
||||
elementType: element.type,
|
||||
trackType: track.type,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return !wouldElementOverlap({
|
||||
elements: track.elements,
|
||||
startTime,
|
||||
endTime: startTime + duration,
|
||||
});
|
||||
});
|
||||
|
||||
if (existingTrack) {
|
||||
editor.timeline.addElementToTrack({
|
||||
trackId: existingTrack.id,
|
||||
element,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const newTrackId = editor.timeline.addTrack({ type: trackType });
|
||||
editor.timeline.addElementToTrack({
|
||||
trackId: newTrackId,
|
||||
element,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const filteredMediaItems = useMemo(() => {
|
||||
let filtered = mediaFiles.filter((item) => {
|
||||
if (item.ephemeral) return false;
|
||||
return true;
|
||||
});
|
||||
const filtered = mediaFiles.filter((item) => !item.ephemeral);
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
let valueA: string | number;
|
||||
|
|
@ -178,84 +206,16 @@ export function MediaView() {
|
|||
const previews = new Map<string, React.ReactNode>();
|
||||
|
||||
filteredMediaItems.forEach((item) => {
|
||||
let preview: React.ReactNode;
|
||||
|
||||
if (item.type === "image") {
|
||||
preview = (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.name}
|
||||
className="max-h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (item.type === "video") {
|
||||
if (item.thumbnailUrl) {
|
||||
preview = (
|
||||
<div className="relative h-full w-full">
|
||||
<img
|
||||
src={item.thumbnailUrl}
|
||||
alt={item.name}
|
||||
className="h-full w-full rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded bg-black/20">
|
||||
<Video className="h-6 w-6 text-white drop-shadow-md" />
|
||||
</div>
|
||||
{item.duration && (
|
||||
<div className="absolute bottom-1 right-1 rounded bg-black/70 px-1 text-xs text-white">
|
||||
{formatDuration(item.duration)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
preview = (
|
||||
<div className="bg-muted/30 text-muted-foreground flex h-full w-full flex-col items-center justify-center rounded">
|
||||
<Video className="mb-1 h-6 w-6" />
|
||||
<span className="text-xs">Video</span>
|
||||
{item.duration && (
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else if (item.type === "audio") {
|
||||
preview = (
|
||||
<div className="bg-linear-to-br text-muted-foreground flex h-full w-full flex-col items-center justify-center rounded border border-green-500/20 from-green-500/20 to-emerald-500/20">
|
||||
<Music className="mb-1 h-6 w-6" />
|
||||
<span className="text-xs">Audio</span>
|
||||
{item.duration && (
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
preview = (
|
||||
<div className="bg-muted/30 text-muted-foreground flex h-full w-full flex-col items-center justify-center rounded">
|
||||
<Image className="h-6 w-6" />
|
||||
<span className="mt-1 text-xs">Unknown</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
previews.set(item.id, preview);
|
||||
previews.set(item.id, <MediaPreview item={item} />);
|
||||
});
|
||||
|
||||
return previews;
|
||||
}, [filteredMediaItems]);
|
||||
|
||||
const renderPreview = (item: MediaFile) => previewComponents.get(item.id);
|
||||
const renderPreview = (item: MediaAsset) => previewComponents.get(item.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* native file picker, visually hidden */}
|
||||
<input {...fileInputProps} />
|
||||
|
||||
<div
|
||||
|
|
@ -265,16 +225,15 @@ export function MediaView() {
|
|||
<div className="bg-panel p-3 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
variant="foreground"
|
||||
onClick={openFilePicker}
|
||||
disabled={isProcessing}
|
||||
className="!bg-background h-9 flex-1 items-center justify-center px-4 opacity-100 transition-opacity hover:opacity-75"
|
||||
className="w-full"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<CloudUpload className="h-4 w-4" />
|
||||
<CloudUpload className="size-4" />
|
||||
)}
|
||||
<span>Upload</span>
|
||||
</Button>
|
||||
|
|
@ -328,70 +287,70 @@ export function MediaView() {
|
|||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortBy === "name") {
|
||||
<SortMenuItem
|
||||
label="Name"
|
||||
sortKey="name"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(
|
||||
sortOrder === "asc" ? "desc" : "asc",
|
||||
);
|
||||
} else {
|
||||
setSortBy("name");
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Name{" "}
|
||||
{sortBy === "name" &&
|
||||
(sortOrder === "asc" ? "↑" : "↓")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortBy === "type") {
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="Type"
|
||||
sortKey="type"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(
|
||||
sortOrder === "asc" ? "desc" : "asc",
|
||||
);
|
||||
} else {
|
||||
setSortBy("type");
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Type{" "}
|
||||
{sortBy === "type" &&
|
||||
(sortOrder === "asc" ? "↑" : "↓")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortBy === "duration") {
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="Duration"
|
||||
sortKey="duration"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(
|
||||
sortOrder === "asc" ? "desc" : "asc",
|
||||
);
|
||||
} else {
|
||||
setSortBy("duration");
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Duration{" "}
|
||||
{sortBy === "duration" &&
|
||||
(sortOrder === "asc" ? "↑" : "↓")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortBy === "size") {
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="File size"
|
||||
sortKey="size"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(
|
||||
sortOrder === "asc" ? "desc" : "asc",
|
||||
);
|
||||
} else {
|
||||
setSortBy("size");
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
>
|
||||
File Size{" "}
|
||||
{sortBy === "size" &&
|
||||
(sortOrder === "asc" ? "↑" : "↓")}
|
||||
</DropdownMenuItem>
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TooltipContent>
|
||||
|
|
@ -419,17 +378,19 @@ export function MediaView() {
|
|||
/>
|
||||
) : mediaViewMode === "grid" ? (
|
||||
<GridView
|
||||
filteredMediaItems={filteredMediaItems}
|
||||
items={filteredMediaItems}
|
||||
renderPreview={renderPreview}
|
||||
handleRemove={handleRemove}
|
||||
onRemove={handleRemove}
|
||||
onAddToTimeline={addElementAtTime}
|
||||
highlightedId={highlightedId}
|
||||
registerElement={registerElement}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
filteredMediaItems={filteredMediaItems}
|
||||
items={filteredMediaItems}
|
||||
renderPreview={renderPreview}
|
||||
handleRemove={handleRemove}
|
||||
onRemove={handleRemove}
|
||||
onAddToTimeline={addElementAtTime}
|
||||
highlightedId={highlightedId}
|
||||
registerElement={registerElement}
|
||||
/>
|
||||
|
|
@ -441,21 +402,52 @@ export function MediaView() {
|
|||
);
|
||||
}
|
||||
|
||||
function MediaItemWithContextMenu({
|
||||
item,
|
||||
children,
|
||||
onRemove,
|
||||
}: {
|
||||
item: MediaAsset;
|
||||
children: React.ReactNode;
|
||||
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Export clips</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={(event) => onRemove({ event, id: item.id })}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function GridView({
|
||||
filteredMediaItems,
|
||||
items,
|
||||
renderPreview,
|
||||
handleRemove,
|
||||
onRemove,
|
||||
onAddToTimeline,
|
||||
highlightedId,
|
||||
registerElement,
|
||||
}: {
|
||||
filteredMediaItems: MediaFile[];
|
||||
renderPreview: (item: MediaFile) => React.ReactNode;
|
||||
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
|
||||
items: MediaAsset[];
|
||||
renderPreview: (item: MediaAsset) => React.ReactNode;
|
||||
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
|
||||
onAddToTimeline: ({
|
||||
asset,
|
||||
startTime,
|
||||
}: {
|
||||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
}) => boolean;
|
||||
highlightedId: string | null;
|
||||
registerElement: (id: string, element: HTMLElement | null) => void;
|
||||
}) {
|
||||
const { addElementAtTime } = useTimelineStore();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
|
|
@ -463,22 +455,23 @@ function GridView({
|
|||
gridTemplateColumns: "repeat(auto-fill, 160px)",
|
||||
}}
|
||||
>
|
||||
{filteredMediaItems.map((item) => (
|
||||
{items.map((item) => (
|
||||
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
|
||||
<MediaItemWithContextMenu item={item} onRemove={handleRemove}>
|
||||
<DraggableMediaItem
|
||||
<MediaItemWithContextMenu item={item} onRemove={onRemove}>
|
||||
<DraggableItem
|
||||
name={item.name}
|
||||
preview={renderPreview(item)}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
type: "media",
|
||||
mediaType: item.type,
|
||||
name: item.name,
|
||||
}}
|
||||
showPlusOnDrag={false}
|
||||
onAddToTimeline={(currentTime) =>
|
||||
addElementAtTime(item, currentTime)
|
||||
shouldShowPlusOnDrag={false}
|
||||
onAddToTimeline={({ currentTime }) =>
|
||||
onAddToTimeline({ asset: item, startTime: currentTime })
|
||||
}
|
||||
rounded={false}
|
||||
isRounded={false}
|
||||
variant="card"
|
||||
isHighlighted={highlightedId === item.id}
|
||||
/>
|
||||
|
|
@ -490,36 +483,43 @@ function GridView({
|
|||
}
|
||||
|
||||
function ListView({
|
||||
filteredMediaItems,
|
||||
items,
|
||||
renderPreview,
|
||||
handleRemove,
|
||||
onRemove,
|
||||
onAddToTimeline,
|
||||
highlightedId,
|
||||
registerElement,
|
||||
}: {
|
||||
filteredMediaItems: MediaFile[];
|
||||
renderPreview: (item: MediaFile) => React.ReactNode;
|
||||
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
|
||||
items: MediaAsset[];
|
||||
renderPreview: (item: MediaAsset) => React.ReactNode;
|
||||
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
|
||||
onAddToTimeline: ({
|
||||
asset,
|
||||
startTime,
|
||||
}: {
|
||||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
}) => boolean;
|
||||
highlightedId: string | null;
|
||||
registerElement: (id: string, element: HTMLElement | null) => void;
|
||||
}) {
|
||||
const { addElementAtTime } = useTimelineStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{filteredMediaItems.map((item) => (
|
||||
{items.map((item) => (
|
||||
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
|
||||
<MediaItemWithContextMenu item={item} onRemove={handleRemove}>
|
||||
<DraggableMediaItem
|
||||
<MediaItemWithContextMenu item={item} onRemove={onRemove}>
|
||||
<DraggableItem
|
||||
name={item.name}
|
||||
preview={renderPreview(item)}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
type: "media",
|
||||
mediaType: item.type,
|
||||
name: item.name,
|
||||
}}
|
||||
showPlusOnDrag={false}
|
||||
onAddToTimeline={(currentTime) =>
|
||||
addElementAtTime(item, currentTime)
|
||||
shouldShowPlusOnDrag={false}
|
||||
onAddToTimeline={({ currentTime }) =>
|
||||
onAddToTimeline({ asset: item, startTime: currentTime })
|
||||
}
|
||||
variant="compact"
|
||||
isHighlighted={highlightedId === item.id}
|
||||
|
|
@ -530,3 +530,176 @@ function ListView({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaPreview({ item }: { item: MediaAsset }) {
|
||||
const formatDuration = (duration: number) => {
|
||||
const min = Math.floor(duration / 60);
|
||||
const sec = Math.floor(duration % 60);
|
||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
if (item.type === "image") {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.name}
|
||||
className="max-h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "video") {
|
||||
if (item.thumbnailUrl) {
|
||||
return (
|
||||
<div className="relative size-full">
|
||||
<img
|
||||
src={item.thumbnailUrl}
|
||||
alt={item.name}
|
||||
className="size-full rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded bg-black/20">
|
||||
<Video className="size-6 text-white drop-shadow-md" />
|
||||
</div>
|
||||
{item.duration && (
|
||||
<div className="absolute bottom-1 right-1 rounded bg-black/70 px-1 text-xs text-white">
|
||||
{formatDuration(item.duration)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-muted/30 text-muted-foreground flex size-full flex-col items-center justify-center rounded">
|
||||
<Video className="mb-1 size-6" />
|
||||
<span className="text-xs">Video</span>
|
||||
{item.duration && (
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "audio") {
|
||||
return (
|
||||
<div className="bg-linear-to-br text-muted-foreground flex size-full flex-col items-center justify-center rounded border border-green-500/20 from-green-500/20 to-emerald-500/20">
|
||||
<Music className="mb-1 size-6" />
|
||||
<span className="text-xs">Audio</span>
|
||||
{item.duration && (
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-muted/30 text-muted-foreground flex size-full flex-col items-center justify-center rounded">
|
||||
<Image className="size-6" />
|
||||
<span className="mt-1 text-xs">Unknown</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortMenuItem({
|
||||
label,
|
||||
sortKey,
|
||||
currentSortBy,
|
||||
currentSortOrder,
|
||||
onSort,
|
||||
}: {
|
||||
label: string;
|
||||
sortKey: "name" | "type" | "duration" | "size";
|
||||
currentSortBy: string;
|
||||
currentSortOrder: "asc" | "desc";
|
||||
onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void;
|
||||
}) {
|
||||
const isActive = currentSortBy === sortKey;
|
||||
const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : "";
|
||||
|
||||
return (
|
||||
<DropdownMenuItem onClick={() => onSort({ key: sortKey })}>
|
||||
{label} {arrow}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function getTrackTypeForMedia({
|
||||
mediaType,
|
||||
}: {
|
||||
mediaType: MediaAsset["type"];
|
||||
}): TrackType {
|
||||
switch (mediaType) {
|
||||
case "video":
|
||||
case "image":
|
||||
return "video";
|
||||
case "audio":
|
||||
return "audio";
|
||||
default:
|
||||
return "video";
|
||||
}
|
||||
}
|
||||
|
||||
function createElementFromMedia({
|
||||
asset,
|
||||
startTime,
|
||||
}: {
|
||||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
}): CreateTimelineElement {
|
||||
const duration =
|
||||
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
|
||||
switch (asset.type) {
|
||||
case "video":
|
||||
return {
|
||||
type: "video",
|
||||
name: asset.name,
|
||||
mediaId: asset.id,
|
||||
startTime,
|
||||
duration,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
muted: false,
|
||||
hidden: false,
|
||||
transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
|
||||
opacity: 1,
|
||||
};
|
||||
case "image":
|
||||
return {
|
||||
type: "image",
|
||||
name: asset.name,
|
||||
mediaId: asset.id,
|
||||
startTime,
|
||||
duration,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
hidden: false,
|
||||
transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
|
||||
opacity: 1,
|
||||
};
|
||||
case "audio":
|
||||
return {
|
||||
type: "audio",
|
||||
sourceType: "upload",
|
||||
name: asset.name,
|
||||
mediaId: asset.id,
|
||||
startTime,
|
||||
duration,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
volume: 1,
|
||||
muted: false,
|
||||
buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unsupported media type: ${asset.type}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ import {
|
|||
PropertyGroup,
|
||||
} from "../../properties-panel/property-item";
|
||||
import {
|
||||
DEFAULT_CANVAS_SIZE,
|
||||
FPS_PRESETS,
|
||||
BLUR_INTENSITY_PRESETS,
|
||||
} from "@/constants/editor-constants";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
DEFAULT_COLOR,
|
||||
} from "@/constants/project-constants";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { dimensionToAspectRatio } from "@/lib/editor-utils";
|
||||
import Image from "next/image";
|
||||
|
|
@ -31,6 +31,8 @@ import { useMemo, memo, useCallback } from "react";
|
|||
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import type { TProject } from "@/types/project";
|
||||
|
||||
export function SettingsView() {
|
||||
return <ProjectSettingsTabs />;
|
||||
|
|
@ -66,7 +68,7 @@ function ProjectSettingsTabs() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Another UI, looks so beautiful I don't wanna remove it */}
|
||||
{/* another ui, looks so beautiful I don't wanna remove it */}
|
||||
{/* <div className="flex flex-col justify-center items-center pb-5 sticky bottom-0">
|
||||
<Button className="w-fit h-auto gap-1.5 px-3.5 py-1.5 bg-foreground hover:bg-foreground/85 text-background rounded-full">
|
||||
<span className="text-sm">Custom</span>
|
||||
|
|
@ -85,17 +87,19 @@ function ProjectSettingsTabs() {
|
|||
function getCurrentCanvasSize({
|
||||
activeProject,
|
||||
}: {
|
||||
activeProject: { canvasSize: { width: number; height: number } } | null;
|
||||
activeProject: TProject;
|
||||
}) {
|
||||
const { canvasSize } = activeProject.settings;
|
||||
|
||||
return {
|
||||
width: activeProject?.canvasSize.width || DEFAULT_CANVAS_SIZE.width,
|
||||
height: activeProject?.canvasSize.height || DEFAULT_CANVAS_SIZE.height,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
};
|
||||
}
|
||||
|
||||
function ProjectInfoView() {
|
||||
const { activeProject, updateProjectFps, updateCanvasSize } =
|
||||
useProjectStore();
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const { canvasPresets } = useEditorStore();
|
||||
|
||||
const findPresetIndexByAspectRatio = ({
|
||||
|
|
@ -131,15 +135,13 @@ function ProjectInfoView() {
|
|||
const index = parseInt(value, 10);
|
||||
const preset = canvasPresets[index];
|
||||
if (preset) {
|
||||
updateCanvasSize({
|
||||
size: preset,
|
||||
});
|
||||
editor.project.updateSettings({ settings: { canvasSize: preset } });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFpsChange = (value: string) => {
|
||||
const fps = parseFloat(value);
|
||||
updateProjectFps(fps);
|
||||
editor.project.updateSettings({ settings: { fps } });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -147,7 +149,7 @@ function ProjectInfoView() {
|
|||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Name</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
{activeProject?.name || "Untitled project"}
|
||||
{activeProject.metadata.name}
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
|
||||
|
|
@ -182,7 +184,7 @@ function ProjectInfoView() {
|
|||
<PropertyItemLabel>Frame rate</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<Select
|
||||
value={(activeProject?.fps || 30).toString()}
|
||||
value={activeProject.settings.fps.toString()}
|
||||
onValueChange={handleFpsChange}
|
||||
>
|
||||
<SelectTrigger className="bg-panel-accent">
|
||||
|
|
@ -249,7 +251,7 @@ const BackgroundPreviews = memo(
|
|||
backgrounds: string[];
|
||||
currentBackgroundColor: string;
|
||||
isColorBackground: boolean;
|
||||
handleColorSelect: (bg: string) => void;
|
||||
handleColorSelect: ({ bg }: { bg: string }) => void;
|
||||
useBackgroundColor?: boolean;
|
||||
}) => {
|
||||
return useMemo(
|
||||
|
|
@ -273,7 +275,7 @@ const BackgroundPreviews = memo(
|
|||
backgroundRepeat: "no-repeat",
|
||||
}
|
||||
}
|
||||
onClick={() => handleColorSelect(bg)}
|
||||
onClick={() => handleColorSelect({ bg })}
|
||||
/>
|
||||
)),
|
||||
[
|
||||
|
|
@ -290,28 +292,40 @@ const BackgroundPreviews = memo(
|
|||
BackgroundPreviews.displayName = "BackgroundPreviews";
|
||||
|
||||
function BackgroundView() {
|
||||
const { activeProject, updateBackgroundType } = useProjectStore();
|
||||
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
|
||||
|
||||
const handleBlurSelect = useCallback(
|
||||
async ({ blurIntensity }: { blurIntensity: number }) => {
|
||||
await updateBackgroundType("blur", { blurIntensity });
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "blur", blurIntensity } },
|
||||
});
|
||||
},
|
||||
[updateBackgroundType],
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const handleColorSelect = useCallback(
|
||||
async (color: string) => {
|
||||
await updateBackgroundType("color", { backgroundColor: color });
|
||||
async ({ color }: { color: string }) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "color", color } },
|
||||
});
|
||||
},
|
||||
[updateBackgroundType],
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const currentBlurIntensity = activeProject?.blurIntensity || 8;
|
||||
const isBlurBackground = activeProject?.backgroundType === "blur";
|
||||
const currentBackgroundColor = activeProject?.backgroundColor || "#000000";
|
||||
const isColorBackground = activeProject?.backgroundType === "color";
|
||||
const currentBlurIntensity =
|
||||
activeProject.settings.background.type === "blur"
|
||||
? activeProject.settings.background.blurIntensity
|
||||
: DEFAULT_BLUR_INTENSITY;
|
||||
|
||||
const currentBackgroundColor =
|
||||
activeProject.settings.background.type === "color"
|
||||
? activeProject.settings.background.color
|
||||
: DEFAULT_COLOR;
|
||||
|
||||
const isBlurBackground = activeProject.settings.background.type === "blur";
|
||||
const isColorBackground = activeProject.settings.background.type === "color";
|
||||
|
||||
const blurPreviews = useMemo(
|
||||
() =>
|
||||
|
|
@ -341,7 +355,7 @@ function BackgroundView() {
|
|||
backgrounds={colors}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={handleColorSelect}
|
||||
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
||||
useBackgroundColor={true}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -353,7 +367,7 @@ function BackgroundView() {
|
|||
backgrounds={patternCraftGradients}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={handleColorSelect}
|
||||
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
|
|
@ -364,7 +378,7 @@ function BackgroundView() {
|
|||
backgrounds={syntaxUIGradients}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={handleColorSelect}
|
||||
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ function SoundEffectsView() {
|
|||
loadMore,
|
||||
hasNextPage,
|
||||
isLoadingMore,
|
||||
} = useSoundSearch(searchQuery, showCommercialOnly);
|
||||
} = useSoundSearch({ query: searchQuery, commercialOnly: showCommercialOnly });
|
||||
|
||||
// Audio playback state
|
||||
const [playingId, setPlayingId] = useState<number | null>(null);
|
||||
|
|
@ -120,8 +120,8 @@ function SoundEffectsView() {
|
|||
const fetchTopSounds = async () => {
|
||||
try {
|
||||
if (!ignore) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading({ loading: true });
|
||||
setError({ error: null });
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
|
|
@ -134,23 +134,24 @@ function SoundEffectsView() {
|
|||
}
|
||||
|
||||
const data = await response.json();
|
||||
setTopSoundEffects(data.results);
|
||||
setHasLoaded(true);
|
||||
setTopSoundEffects({ sounds: data.results });
|
||||
setHasLoaded({ loaded: true });
|
||||
|
||||
setCurrentPage(1);
|
||||
setHasNextPage(!!data.next);
|
||||
setTotalCount(data.count);
|
||||
setCurrentPage({ page: 1 });
|
||||
setHasNextPage({ hasNext: !!data.next });
|
||||
setTotalCount({ count: data.count });
|
||||
}
|
||||
} catch (error) {
|
||||
if (!ignore) {
|
||||
console.error("Failed to fetch top sounds:", error);
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Failed to load sounds"
|
||||
);
|
||||
setError({
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to load sounds",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!ignore) {
|
||||
setLoading(false);
|
||||
setLoading({ loading: false });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -183,7 +184,7 @@ function SoundEffectsView() {
|
|||
|
||||
const handleScrollWithPosition = (event: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop } = event.currentTarget;
|
||||
setScrollPosition(scrollTop);
|
||||
setScrollPosition({ position: scrollTop });
|
||||
handleScroll(event);
|
||||
};
|
||||
|
||||
|
|
@ -227,9 +228,9 @@ function SoundEffectsView() {
|
|||
className="bg-panel-accent w-full"
|
||||
containerClassName="w-full"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={(e) => setSearchQuery({ query: e.target.value })}
|
||||
showClearIcon
|
||||
onClear={() => setSearchQuery("")}
|
||||
onClear={() => setSearchQuery({ query: "" })}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
@ -244,7 +245,7 @@ function SoundEffectsView() {
|
|||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showCommercialOnly}
|
||||
onCheckedChange={toggleCommercialFilter}
|
||||
onCheckedChange={() => toggleCommercialFilter()}
|
||||
>
|
||||
Show only commercially licensed
|
||||
</DropdownMenuCheckboxItem>
|
||||
|
|
@ -278,8 +279,8 @@ function SoundEffectsView() {
|
|||
sound={sound}
|
||||
isPlaying={playingId === sound.id}
|
||||
onPlay={() => playSound(sound)}
|
||||
isSaved={isSoundSaved(sound.id)}
|
||||
onToggleSaved={() => toggleSavedSound(sound)}
|
||||
isSaved={isSoundSaved({ soundId: sound.id })}
|
||||
onToggleSaved={() => toggleSavedSound({ soundEffect: sound })}
|
||||
/>
|
||||
))}
|
||||
{!isLoading && !isSearching && displayedSounds.length === 0 && (
|
||||
|
|
@ -464,9 +465,9 @@ function SavedSoundsView() {
|
|||
sound={convertToSoundEffect(sound)}
|
||||
isPlaying={playingId === sound.id}
|
||||
onPlay={() => playSound(sound)}
|
||||
isSaved={isSoundSaved(sound.id)}
|
||||
isSaved={isSoundSaved({ soundId: sound.id })}
|
||||
onToggleSaved={() =>
|
||||
toggleSavedSound(convertToSoundEffect(sound))
|
||||
toggleSavedSound({ soundEffect: convertToSoundEffect(sound) })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -509,7 +510,7 @@ function AudioItem({
|
|||
|
||||
const handleAddToTimeline = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await addSoundToTimeline(sound);
|
||||
await addSoundToTimeline({ sound });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -32,9 +32,10 @@ import {
|
|||
} from "@/lib/iconify-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { DraggableItem } from "@/components/ui/draggable-item";
|
||||
import { InputWithBack } from "@/components/ui/input-with-back";
|
||||
import { StickerCategory } from "@/stores/stickers-store";
|
||||
import type { StickerCategory } from "@/types/stickers";
|
||||
import { STICKER_CATEGORIES } from "@/constants/stickers-constants";
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
|
||||
export function StickersView() {
|
||||
|
|
@ -44,8 +45,8 @@ export function StickersView() {
|
|||
<BaseView
|
||||
value={selectedCategory}
|
||||
onValueChange={(v) => {
|
||||
if (["all", "general", "brands", "emoji"].includes(v)) {
|
||||
setSelectedCategory(v as StickerCategory);
|
||||
if (STICKER_CATEGORIES.includes(v as StickerCategory)) {
|
||||
setSelectedCategory({ category: v as StickerCategory });
|
||||
}
|
||||
}}
|
||||
tabs={[
|
||||
|
|
@ -74,7 +75,7 @@ export function StickersView() {
|
|||
content: <StickersContentView category="emoji" />,
|
||||
},
|
||||
]}
|
||||
className="flex flex-col h-full p-0 overflow-hidden"
|
||||
className="flex h-full flex-col overflow-hidden p-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -124,7 +125,7 @@ function CollectionGrid({
|
|||
total: number;
|
||||
category?: string;
|
||||
}>;
|
||||
onSelectCollection: (prefix: string) => void;
|
||||
onSelectCollection: ({ prefix }: { prefix: string }) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
|
|
@ -133,7 +134,7 @@ function CollectionGrid({
|
|||
key={collection.prefix}
|
||||
title={collection.name}
|
||||
subtitle={`${collection.total.toLocaleString()} icons${collection.category ? ` • ${collection.category}` : ""}`}
|
||||
onClick={() => onSelectCollection(collection.prefix)}
|
||||
onClick={() => onSelectCollection({ prefix: collection.prefix })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -142,14 +143,14 @@ function CollectionGrid({
|
|||
|
||||
function EmptyView({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3">
|
||||
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<StickerIcon
|
||||
className="w-10 h-10 text-muted-foreground"
|
||||
className="text-muted-foreground h-10 w-10"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<p className="text-lg font-medium">No stickers found</p>
|
||||
<p className="text-sm text-muted-foreground text-balance">{message}</p>
|
||||
<p className="text-muted-foreground text-balance text-sm">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -229,9 +230,9 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
|||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (localSearchQuery !== searchQuery) {
|
||||
setSearchQuery(localSearchQuery);
|
||||
setSearchQuery({ query: localSearchQuery });
|
||||
if (localSearchQuery.trim()) {
|
||||
searchStickers(localSearchQuery);
|
||||
searchStickers({ query: localSearchQuery });
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
|
@ -241,7 +242,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
|||
|
||||
const handleAddSticker = async (iconName: string) => {
|
||||
try {
|
||||
await addStickerToTimeline(iconName);
|
||||
await addStickerToTimeline({ iconName });
|
||||
} catch (error) {
|
||||
console.error("Failed to add sticker:", error);
|
||||
toast.error("Failed to add sticker to timeline");
|
||||
|
|
@ -259,8 +260,8 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
|||
if (currentCollection.uncategorized) {
|
||||
icons.push(
|
||||
...currentCollection.uncategorized.map(
|
||||
(name) => `${currentCollection.prefix}:${name}`
|
||||
)
|
||||
(name) => `${currentCollection.prefix}:${name}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -268,8 +269,8 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
|||
Object.values(currentCollection.categories).forEach((categoryIcons) => {
|
||||
icons.push(
|
||||
...categoryIcons.map(
|
||||
(name) => `${currentCollection.prefix}:${name}`
|
||||
)
|
||||
(name) => `${currentCollection.prefix}:${name}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -293,13 +294,13 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
|||
}, [isInCollection]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 mt-1 h-full p-4">
|
||||
<div className="mt-1 flex h-full flex-col gap-5 p-4">
|
||||
<div className="space-y-3">
|
||||
<InputWithBack
|
||||
isExpanded={isInCollection}
|
||||
setIsExpanded={(expanded) => {
|
||||
if (!expanded && isInCollection) {
|
||||
setSelectedCollection(null);
|
||||
setSelectedCollection({ collection: null });
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
|
|
@ -319,24 +320,24 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
|||
|
||||
<div className="relative h-full overflow-hidden">
|
||||
<ScrollArea
|
||||
className="flex-1 h-full"
|
||||
className="h-full flex-1"
|
||||
ref={scrollAreaRef}
|
||||
onScrollCapture={handleScroll}
|
||||
>
|
||||
<div className="flex flex-col gap-4 h-full">
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
{recentStickers.length > 0 && viewMode === "browse" && (
|
||||
<div className="h-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Clock className="text-muted-foreground h-4 w-4" />
|
||||
<span className="text-sm font-medium">Recent</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={clearRecentStickers}
|
||||
className="ml-auto h-5 w-5 p-0 rounded hover:bg-accent flex items-center justify-center"
|
||||
className="hover:bg-accent ml-auto flex h-5 w-5 items-center justify-center rounded p-0"
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
<X className="text-muted-foreground h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
|
@ -358,7 +359,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
|||
<div className="h-full">
|
||||
{isLoadingCollection ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : showCollectionItems ? (
|
||||
<StickerGrid
|
||||
|
|
@ -368,7 +369,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
|||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -378,12 +379,12 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
|||
<div className="h-full">
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : searchResults?.icons.length ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{searchResults.total} results
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -395,7 +396,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
|||
/>
|
||||
</>
|
||||
) : searchQuery ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-3">
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
||||
<EmptyView
|
||||
message={`No stickers found for "${searchQuery}"`}
|
||||
/>
|
||||
|
|
@ -405,11 +406,11 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
|||
onClick={() => {
|
||||
const q = localSearchQuery || searchQuery;
|
||||
if (q) {
|
||||
setSearchQuery(q);
|
||||
setSearchQuery({ query: q });
|
||||
}
|
||||
setSelectedCategory("all");
|
||||
setSelectedCategory({ category: "all" });
|
||||
if (q) {
|
||||
searchStickers(q);
|
||||
searchStickers({ query: q });
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
@ -422,26 +423,20 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
|||
)}
|
||||
|
||||
{viewMode === "browse" && !selectedCollection && (
|
||||
<div className="space-y-4 h-full">
|
||||
<div className="h-full space-y-4">
|
||||
{isLoadingCollections ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{category !== "all" && (
|
||||
<div className="h-full">
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
Popular{" "}
|
||||
{category === "general"
|
||||
? "Icon Sets"
|
||||
: category === "brands"
|
||||
? "Brand Icons"
|
||||
: "Emoji Sets"}
|
||||
</h3>
|
||||
<CollectionGrid
|
||||
collections={filteredCollections}
|
||||
onSelectCollection={setSelectedCollection}
|
||||
onSelectCollection={({ prefix }) =>
|
||||
setSelectedCollection({ collection: prefix })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -451,9 +446,11 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
|||
<CollectionGrid
|
||||
collections={filteredCollections.slice(
|
||||
0,
|
||||
collectionsToShow
|
||||
collectionsToShow,
|
||||
)}
|
||||
onSelectCollection={setSelectedCollection}
|
||||
onSelectCollection={({ prefix }) =>
|
||||
setSelectedCollection({ collection: prefix })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -478,12 +475,12 @@ function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) {
|
|||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-between h-auto py-2 "
|
||||
className="h-auto justify-between py-2 rounded-md"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="text-left">
|
||||
<p className="font-medium">{title}</p>
|
||||
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||||
<p className="text-muted-foreground text-xs">{subtitle}</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -515,13 +512,13 @@ function StickerItem({
|
|||
const collectionPrefix = iconName.split(":")[0];
|
||||
|
||||
const preview = imageError ? (
|
||||
<div className="w-full h-full flex items-center justify-center p-2">
|
||||
<span className="text-xs text-muted-foreground text-center break-all">
|
||||
<div className="flex h-full w-full items-center justify-center p-2">
|
||||
<span className="text-muted-foreground break-all text-center text-xs">
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full p-4 flex items-center justify-center">
|
||||
<div className="flex h-full w-full items-center justify-center p-4">
|
||||
<Image
|
||||
src={
|
||||
hostIndex === 0
|
||||
|
|
@ -529,13 +526,13 @@ function StickerItem({
|
|||
: buildIconSvgUrl(
|
||||
ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)],
|
||||
iconName,
|
||||
{ width: 64, height: 64 }
|
||||
{ width: 64, height: 64 },
|
||||
)
|
||||
}
|
||||
alt={displayName}
|
||||
width={64}
|
||||
height={64}
|
||||
className="w-full h-full object-contain"
|
||||
className="h-full w-full object-contain"
|
||||
style={
|
||||
capSize
|
||||
? {
|
||||
|
|
@ -564,28 +561,28 @@ function StickerItem({
|
|||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
isAdding && "opacity-50 pointer-events-none"
|
||||
isAdding && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
<DraggableMediaItem
|
||||
<DraggableItem
|
||||
name={displayName}
|
||||
preview={preview}
|
||||
dragData={{
|
||||
id: "sticker-placeholder",
|
||||
type: "image",
|
||||
id: iconName,
|
||||
type: "sticker",
|
||||
name: displayName,
|
||||
iconName,
|
||||
}}
|
||||
onAddToTimeline={() => onAdd(iconName)}
|
||||
aspectRatio={1}
|
||||
showLabel={false}
|
||||
rounded={true}
|
||||
shouldShowLabel={false}
|
||||
isRounded={true}
|
||||
variant="card"
|
||||
className=""
|
||||
containerClassName="w-full"
|
||||
isDraggable={false}
|
||||
/>
|
||||
{isAdding && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-md z-10">
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -594,7 +591,7 @@ function StickerItem({
|
|||
<TooltipContent>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{displayName}</p>
|
||||
<p className="text-xs text-muted-foreground">{collectionPrefix}</p>
|
||||
<p className="text-muted-foreground text-xs">{collectionPrefix}</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,36 @@
|
|||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { DraggableItem } from "@/components/ui/draggable-item";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
||||
import { buildTextElement } from "@/lib/timeline/element-utils";
|
||||
|
||||
export function TextView() {
|
||||
const editor = useEditor();
|
||||
|
||||
const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => {
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
if (!activeScene) return;
|
||||
|
||||
const element = buildTextElement({
|
||||
raw: DEFAULT_TEXT_ELEMENT,
|
||||
startTime: currentTime,
|
||||
});
|
||||
const textTrack = activeScene.tracks.find((t) => t.type === "text");
|
||||
if (textTrack) {
|
||||
editor.timeline.addElementToTrack({
|
||||
trackId: textTrack.id,
|
||||
element,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseView>
|
||||
<DraggableMediaItem
|
||||
<DraggableItem
|
||||
name="Default text"
|
||||
preview={
|
||||
<div className="flex items-center justify-center w-full h-full bg-panel-accent rounded">
|
||||
<span className="text-xs select-none">Default text</span>
|
||||
<div className="bg-panel-accent flex size-full items-center justify-center rounded">
|
||||
<span className="select-none text-xs">Default text</span>
|
||||
</div>
|
||||
}
|
||||
dragData={{
|
||||
|
|
@ -20,16 +40,8 @@ export function TextView() {
|
|||
content: DEFAULT_TEXT_ELEMENT.content,
|
||||
}}
|
||||
aspectRatio={1}
|
||||
onAddToTimeline={(currentTime) =>
|
||||
useTimelineStore.getState().addElementAtTime(
|
||||
{
|
||||
...DEFAULT_TEXT_ELEMENT,
|
||||
id: "temp-text-id",
|
||||
},
|
||||
currentTime
|
||||
)
|
||||
}
|
||||
showLabel={false}
|
||||
onAddToTimeline={handleAddToTimeline}
|
||||
shouldShowLabel={false}
|
||||
/>
|
||||
</BaseView>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
Trash,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { EditorCore } from "@/core";
|
||||
import { KeyboardShortcutsHelp } from "../keyboard-shortcuts-help";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
|
|
@ -28,6 +27,7 @@ import { ExportButton } from "./export-button";
|
|||
import { ThemeToggle } from "../theme-toggle";
|
||||
import { SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
import { toast } from "sonner";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
export function EditorHeader() {
|
||||
return (
|
||||
|
|
@ -50,8 +50,8 @@ function ProjectDropdown() {
|
|||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const router = useRouter();
|
||||
const editor = EditorCore.getInstance();
|
||||
const activeProject = editor.project.getActive();
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActiveOrNull();
|
||||
|
||||
const handleExit = async () => {
|
||||
if (isExiting) return;
|
||||
|
|
@ -69,10 +69,14 @@ function ProjectDropdown() {
|
|||
};
|
||||
|
||||
const handleSaveProjectName = async (newName: string) => {
|
||||
if (activeProject && newName.trim() && newName !== activeProject.name) {
|
||||
if (
|
||||
activeProject &&
|
||||
newName.trim() &&
|
||||
newName !== activeProject.metadata.name
|
||||
) {
|
||||
try {
|
||||
await editor.project.renameProject({
|
||||
id: activeProject.id,
|
||||
id: activeProject.metadata.id,
|
||||
name: newName.trim(),
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -89,7 +93,7 @@ function ProjectDropdown() {
|
|||
const handleDeleteProject = async () => {
|
||||
if (activeProject) {
|
||||
try {
|
||||
await editor.project.deleteProject({ id: activeProject.id });
|
||||
await editor.project.deleteProject({ id: activeProject.metadata.id });
|
||||
router.push("/projects");
|
||||
} catch (error) {
|
||||
toast.error("Failed to delete project", {
|
||||
|
|
@ -111,7 +115,9 @@ function ProjectDropdown() {
|
|||
className="flex h-auto items-center justify-center px-2.5 py-1.5"
|
||||
>
|
||||
<ChevronDown className="text-muted-foreground" />
|
||||
<span className="mr-2 text-[0.85rem]">{activeProject?.name}</span>
|
||||
<span className="mr-2 text-[0.85rem]">
|
||||
{activeProject?.metadata.name}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="z-100 w-40">
|
||||
|
|
@ -160,13 +166,13 @@ function ProjectDropdown() {
|
|||
isOpen={isRenameDialogOpen}
|
||||
onOpenChange={setIsRenameDialogOpen}
|
||||
onConfirm={(newName) => handleSaveProjectName(newName)}
|
||||
projectName={activeProject?.name || ""}
|
||||
projectName={activeProject?.metadata.name || ""}
|
||||
/>
|
||||
<DeleteProjectDialog
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
onConfirm={handleDeleteProject}
|
||||
projectName={activeProject?.name || ""}
|
||||
projectName={activeProject?.metadata.name || ""}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@ import {
|
|||
exportProject,
|
||||
getExportMimeType,
|
||||
getExportFileExtension,
|
||||
DEFAULT_EXPORT_OPTIONS,
|
||||
} from "@/lib/export-utils";
|
||||
import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
|
||||
import { ExportFormat, ExportQuality, ExportResult } from "@/types/export";
|
||||
import { PropertyGroup } from "./properties-panel/property-item";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
|
||||
|
||||
export function ExportButton() {
|
||||
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
|
||||
|
|
@ -28,7 +28,7 @@ export function ExportButton() {
|
|||
setIsExportPopoverOpen(true);
|
||||
};
|
||||
|
||||
const hasProject = !!editor.project.activeProject;
|
||||
const hasProject = !!editor.project.getActive();
|
||||
|
||||
return (
|
||||
<Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}>
|
||||
|
|
@ -70,7 +70,7 @@ function ExportPopover({
|
|||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.activeProject;
|
||||
const activeProject = editor.project.getActive();
|
||||
const [format, setFormat] = useState<ExportFormat>(
|
||||
DEFAULT_EXPORT_OPTIONS.format,
|
||||
);
|
||||
|
|
@ -94,10 +94,10 @@ function ExportPopover({
|
|||
const result = await exportProject({
|
||||
format,
|
||||
quality,
|
||||
fps: activeProject.fps,
|
||||
fps: activeProject.settings.fps,
|
||||
includeAudio,
|
||||
onProgress: setProgress,
|
||||
onCancel: () => false, // TODO: Add cancel functionality
|
||||
onProgress: ({ progress }) => setProgress(progress),
|
||||
onCancel: () => false, // TODO: add cancel functionality
|
||||
});
|
||||
|
||||
setIsExporting(false);
|
||||
|
|
@ -112,7 +112,7 @@ function ExportPopover({
|
|||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${activeProject.name}${extension}`;
|
||||
a.download = `${activeProject.metadata.name}${extension}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
|
@ -287,7 +287,7 @@ function ExportError({
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium text-destructive">Export failed</p>
|
||||
<p className="text-destructive text-sm font-medium">Export failed</p>
|
||||
<p className="text-muted-foreground text-xs">{error}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function MigrationDialog() {
|
||||
const editor = useEditor();
|
||||
const migrationState = editor.project.getMigrationState();
|
||||
|
||||
if (!migrationState.isMigrating) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={true}>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Updating project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upgrading "{migrationState.projectName}" from v
|
||||
{migrationState.fromVersion} to v{migrationState.toVersion}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -53,7 +53,7 @@ export function PanelBaseView({
|
|||
className="flex flex-col h-full"
|
||||
>
|
||||
<div className="sticky top-0 z-10 bg-panel">
|
||||
<div className="px-3 pt-3.5 pb-0">
|
||||
<div className="px-3 pt-3 pb-0">
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
|
|
@ -67,7 +67,7 @@ export function PanelBaseView({
|
|||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
<Separator className="mt-3.5" />
|
||||
<Separator className="mt-3" />
|
||||
</div>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent
|
||||
|
|
|
|||
|
|
@ -5,60 +5,41 @@ import { useCallback, useMemo, useRef } from "react";
|
|||
import { useRafLoop } from "@/hooks/use-raf-loop";
|
||||
import { RootNode } from "@/services/renderer/nodes/root-node";
|
||||
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useRendererStore } from "@/stores/renderer-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { buildScene } from "@/services/renderer/scene-builder";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
function usePreviewSize() {
|
||||
const { activeProject } = useProjectStore();
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
return {
|
||||
width: activeProject?.canvasSize?.width || 600,
|
||||
height: activeProject?.canvasSize?.height || 320,
|
||||
width: activeProject?.settings.canvasSize.width,
|
||||
height: activeProject?.settings.canvasSize.height,
|
||||
};
|
||||
}
|
||||
|
||||
function RenderTreeController() {
|
||||
const setRenderTree = useRendererStore((s) => s.setRenderTree);
|
||||
const tracks = useTimelineStore((s) => s.tracks);
|
||||
const mediaFiles = useMediaStore((s) => s.mediaFiles);
|
||||
const getTotalDuration = useTimelineStore((s) => s.getTotalDuration);
|
||||
const { activeProject } = useProjectStore();
|
||||
const editor = useEditor();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const { width, height } = usePreviewSize();
|
||||
|
||||
useDeepCompareEffect(() => {
|
||||
if (!activeProject) return;
|
||||
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const renderTree = buildScene({
|
||||
tracks,
|
||||
mediaFiles,
|
||||
duration: getTotalDuration(),
|
||||
canvasSize: {
|
||||
width,
|
||||
height,
|
||||
},
|
||||
backgroundColor:
|
||||
activeProject.backgroundType === "blur"
|
||||
? "transparent"
|
||||
: activeProject.backgroundColor || "#000000",
|
||||
backgroundType: activeProject.backgroundType,
|
||||
blurIntensity: activeProject.blurIntensity,
|
||||
mediaAssets,
|
||||
duration,
|
||||
canvasSize: { width, height },
|
||||
background: activeProject.settings.background,
|
||||
});
|
||||
|
||||
setRenderTree(renderTree);
|
||||
}, [
|
||||
tracks,
|
||||
mediaFiles,
|
||||
getTotalDuration,
|
||||
activeProject?.backgroundColor,
|
||||
activeProject?.backgroundType,
|
||||
activeProject?.blurIntensity,
|
||||
width,
|
||||
height,
|
||||
]);
|
||||
editor.renderer.setRenderTree({ renderTree });
|
||||
}, [tracks, mediaAssets, activeProject?.settings.background, width, height]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -80,21 +61,22 @@ function PreviewCanvas() {
|
|||
const lastSceneRef = useRef<RootNode | null>(null);
|
||||
const renderingRef = useRef(false);
|
||||
const { width, height } = usePreviewSize();
|
||||
const { activeProject } = useProjectStore();
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const renderer = useMemo(() => {
|
||||
return new CanvasRenderer({
|
||||
width,
|
||||
height,
|
||||
fps: activeProject?.fps || DEFAULT_FPS,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
}, [width, height, activeProject?.fps]);
|
||||
}, [width, height, activeProject.settings.fps]);
|
||||
|
||||
const renderTree = useRendererStore((s) => s.renderTree);
|
||||
const renderTree = editor.renderer.getRenderTree();
|
||||
|
||||
const render = useCallback(() => {
|
||||
if (ref.current && renderTree && !renderingRef.current) {
|
||||
const time = usePlaybackStore.getState().currentTime;
|
||||
const time = editor.playback.getCurrentTime();
|
||||
const frame = Math.floor(time * renderer.fps);
|
||||
|
||||
if (
|
||||
|
|
@ -111,7 +93,7 @@ function PreviewCanvas() {
|
|||
});
|
||||
}
|
||||
}
|
||||
}, [renderer, renderTree]);
|
||||
}, [renderer, renderTree, editor.playback]);
|
||||
|
||||
useRafLoop(render);
|
||||
|
||||
|
|
@ -123,9 +105,9 @@ function PreviewCanvas() {
|
|||
className="block max-h-full max-w-full border"
|
||||
style={{
|
||||
background:
|
||||
activeProject?.backgroundType === "blur"
|
||||
activeProject.settings.background.type === "blur"
|
||||
? "transparent"
|
||||
: activeProject?.backgroundColor || "#000000",
|
||||
: activeProject?.settings.background.color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,44 +1,41 @@
|
|||
"use client";
|
||||
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { ScrollArea } from "../../ui/scroll-area";
|
||||
import { AudioProperties } from "./audio-properties";
|
||||
import { MediaProperties } from "./media-properties";
|
||||
import { VideoProperties } from "./video-properties";
|
||||
import { TextProperties } from "./text-properties";
|
||||
import { SquareSlashIcon } from "lucide-react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { selectedElements, tracks } = useTimelineStore();
|
||||
const { mediaFiles } = useMediaStore();
|
||||
const { selectedElements } = useTimelineStore();
|
||||
|
||||
const editor = useEditor();
|
||||
|
||||
const elementsWithTracks = editor.timeline.getElementsWithTracks({
|
||||
elements: selectedElements,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedElements.length > 0 ? (
|
||||
<ScrollArea className="h-full bg-panel rounded-sm">
|
||||
{selectedElements.map(({ trackId, elementId }) => {
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
|
||||
if (element?.type === "text") {
|
||||
<ScrollArea className="bg-panel h-full rounded-sm">
|
||||
{elementsWithTracks.map(({ track, element }) => {
|
||||
if (element.type === "text") {
|
||||
return (
|
||||
<div key={elementId}>
|
||||
<TextProperties element={element} trackId={trackId} />
|
||||
<div key={element.id}>
|
||||
<TextProperties element={element} trackId={track.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (element?.type === "media") {
|
||||
const mediaFile = mediaFiles.find(
|
||||
(file) => file.id === element.mediaId
|
||||
);
|
||||
|
||||
if (mediaFile?.type === "audio") {
|
||||
return <AudioProperties key={elementId} element={element} />;
|
||||
}
|
||||
|
||||
if (element.type === "audio") {
|
||||
return <AudioProperties key={element.id} element={element} />;
|
||||
}
|
||||
if (element.type === "video" || element.type === "image") {
|
||||
return (
|
||||
<div key={elementId}>
|
||||
<MediaProperties element={element} />
|
||||
<div key={element.id}>
|
||||
<VideoProperties element={element} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -54,14 +51,14 @@ export function PropertiesPanel() {
|
|||
|
||||
function EmptyView() {
|
||||
return (
|
||||
<div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3">
|
||||
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<SquareSlashIcon
|
||||
className="w-10 h-10 text-muted-foreground"
|
||||
className="text-muted-foreground h-10 w-10"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<p className="text-lg font-medium">It’s empty here</p>
|
||||
<p className="text-sm text-muted-foreground text-balance">
|
||||
<p className="text-muted-foreground text-balance text-sm">
|
||||
Click an element on the timeline to edit its properties
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { Textarea } from "@/components/ui/textarea";
|
|||
import { FontPicker } from "@/components/ui/font-picker";
|
||||
import { FontFamily } from "@/constants/font-constants";
|
||||
import { TextElement } from "@/types/timeline";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -19,13 +18,15 @@ import {
|
|||
PropertyItemValue,
|
||||
} from "./property-item";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { cn, uppercase } from "@/lib/utils";
|
||||
import { cn, capitalizeFirstLetter, clamp } from "@/lib/utils";
|
||||
import { Grid2x2 } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_COLOR } from "@/constants/project-constants";
|
||||
|
||||
export function TextProperties({
|
||||
element,
|
||||
|
|
@ -34,88 +35,94 @@ export function TextProperties({
|
|||
element: TextElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const { updateTextElement } = useTimelineStore();
|
||||
const editor = useEditor();
|
||||
const { activeTab, setActiveTab } = useTextPropertiesStore();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Local state for input values to allow temporary empty/invalid states
|
||||
const [fontSizeInput, setFontSizeInput] = useState(
|
||||
element.fontSize.toString()
|
||||
element.fontSize.toString(),
|
||||
);
|
||||
const [opacityInput, setOpacityInput] = useState(
|
||||
Math.round(element.opacity * 100).toString()
|
||||
Math.round(element.opacity * 100).toString(),
|
||||
);
|
||||
|
||||
// Track the last selected color for toggling
|
||||
const lastSelectedColor = useRef("#000000");
|
||||
const lastSelectedColor = useRef(DEFAULT_COLOR);
|
||||
|
||||
const parseAndValidateNumber = (
|
||||
value: string,
|
||||
min: number,
|
||||
max: number,
|
||||
fallback: number
|
||||
): number => {
|
||||
const parsed = parseInt(value, 10);
|
||||
if (isNaN(parsed)) return fallback;
|
||||
return Math.max(min, Math.min(max, parsed));
|
||||
};
|
||||
|
||||
const handleFontSizeChange = (value: string) => {
|
||||
const handleFontSizeChange = ({ value }: { value: string }) => {
|
||||
setFontSizeInput(value);
|
||||
|
||||
if (value.trim() !== "") {
|
||||
const fontSize = parseAndValidateNumber(value, 8, 300, element.fontSize);
|
||||
updateTextElement(trackId, element.id, { fontSize });
|
||||
const parsed = parseInt(value, 10);
|
||||
const fontSize = isNaN(parsed)
|
||||
? element.fontSize
|
||||
: clamp({ value: parsed, min: 8, max: 300 });
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFontSizeBlur = () => {
|
||||
const fontSize = parseAndValidateNumber(
|
||||
fontSizeInput,
|
||||
8,
|
||||
300,
|
||||
element.fontSize
|
||||
);
|
||||
const parsed = parseInt(fontSizeInput, 10);
|
||||
const fontSize = isNaN(parsed)
|
||||
? element.fontSize
|
||||
: clamp({ value: parsed, min: 8, max: 300 });
|
||||
setFontSizeInput(fontSize.toString());
|
||||
updateTextElement(trackId, element.id, { fontSize });
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize },
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpacityChange = (value: string) => {
|
||||
const handleOpacityChange = ({ value }: { value: string }) => {
|
||||
setOpacityInput(value);
|
||||
|
||||
if (value.trim() !== "") {
|
||||
const opacityPercent = parseAndValidateNumber(
|
||||
value,
|
||||
0,
|
||||
100,
|
||||
Math.round(element.opacity * 100)
|
||||
);
|
||||
updateTextElement(trackId, element.id, { opacity: opacityPercent / 100 });
|
||||
const parsed = parseInt(value, 10);
|
||||
const opacityPercent = isNaN(parsed)
|
||||
? Math.round(element.opacity * 100)
|
||||
: clamp({ value: parsed, min: 0, max: 100 });
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: opacityPercent / 100 },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpacityBlur = () => {
|
||||
const opacityPercent = parseAndValidateNumber(
|
||||
opacityInput,
|
||||
0,
|
||||
100,
|
||||
Math.round(element.opacity * 100)
|
||||
);
|
||||
const parsed = parseInt(opacityInput, 10);
|
||||
const opacityPercent = isNaN(parsed)
|
||||
? Math.round(element.opacity * 100)
|
||||
: clamp({ value: parsed, min: 0, max: 100 });
|
||||
setOpacityInput(opacityPercent.toString());
|
||||
updateTextElement(trackId, element.id, { opacity: opacityPercent / 100 });
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: opacityPercent / 100 },
|
||||
});
|
||||
};
|
||||
|
||||
// Update last selected color when a new color is picked
|
||||
const handleColorChange = (color: string) => {
|
||||
const handleColorChange = ({ color }: { color: string }) => {
|
||||
if (color !== "transparent") {
|
||||
lastSelectedColor.current = color;
|
||||
}
|
||||
updateTextElement(trackId, element.id, { backgroundColor: color });
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { backgroundColor: color },
|
||||
});
|
||||
};
|
||||
|
||||
// Toggle between transparent and last selected color
|
||||
const handleTransparentToggle = (isTransparent: boolean) => {
|
||||
const handleTransparentToggle = ({ isTransparent }: { isTransparent: boolean }) => {
|
||||
const newColor = isTransparent ? "transparent" : lastSelectedColor.current;
|
||||
updateTextElement(trackId, element.id, { backgroundColor: newColor });
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { backgroundColor: newColor },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -137,10 +144,12 @@ export function TextProperties({
|
|||
<Textarea
|
||||
placeholder="Name"
|
||||
defaultValue={element.content}
|
||||
className="min-h-18 resize-none bg-panel-accent"
|
||||
className="min-h-18 bg-panel-accent resize-none"
|
||||
onChange={(e) =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
content: e.target.value,
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { content: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
|
@ -150,8 +159,10 @@ export function TextProperties({
|
|||
<FontPicker
|
||||
defaultValue={element.fontFamily}
|
||||
onValueChange={(value: FontFamily) =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
fontFamily: value,
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontFamily: value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
|
@ -167,9 +178,13 @@ export function TextProperties({
|
|||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
fontWeight:
|
||||
element.fontWeight === "bold" ? "normal" : "bold",
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
fontWeight:
|
||||
element.fontWeight === "bold" ? "normal" : "bold",
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 font-bold"
|
||||
|
|
@ -182,11 +197,15 @@ export function TextProperties({
|
|||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
fontStyle:
|
||||
element.fontStyle === "italic"
|
||||
? "normal"
|
||||
: "italic",
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
fontStyle:
|
||||
element.fontStyle === "italic"
|
||||
? "normal"
|
||||
: "italic",
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 italic"
|
||||
|
|
@ -201,11 +220,15 @@ export function TextProperties({
|
|||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
textDecoration:
|
||||
element.textDecoration === "underline"
|
||||
? "none"
|
||||
: "underline",
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
textDecoration:
|
||||
element.textDecoration === "underline"
|
||||
? "none"
|
||||
: "underline",
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 underline"
|
||||
|
|
@ -220,11 +243,15 @@ export function TextProperties({
|
|||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
textDecoration:
|
||||
element.textDecoration === "line-through"
|
||||
? "none"
|
||||
: "line-through",
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
textDecoration:
|
||||
element.textDecoration === "line-through"
|
||||
? "none"
|
||||
: "line-through",
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 line-through"
|
||||
|
|
@ -244,8 +271,10 @@ export function TextProperties({
|
|||
max={300}
|
||||
step={1}
|
||||
onValueChange={([value]) => {
|
||||
updateTextElement(trackId, element.id, {
|
||||
fontSize: value,
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize: value },
|
||||
});
|
||||
setFontSizeInput(value.toString());
|
||||
}}
|
||||
|
|
@ -256,12 +285,9 @@ export function TextProperties({
|
|||
value={fontSizeInput}
|
||||
min={8}
|
||||
max={300}
|
||||
onChange={(e) => handleFontSizeChange(e.target.value)}
|
||||
onChange={(e) => handleFontSizeChange({ value: e.target.value })}
|
||||
onBlur={handleFontSizeBlur}
|
||||
className="w-12 px-2 !text-xs h-7 rounded-sm text-center bg-panel-accent
|
||||
[appearance:textfield]
|
||||
[&::-webkit-outer-spin-button]:appearance-none
|
||||
[&::-webkit-inner-spin-button]:appearance-none"
|
||||
className="bg-panel-accent h-7 w-12 rounded-sm px-2 text-center !text-xs [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
|
|
@ -270,12 +296,14 @@ export function TextProperties({
|
|||
<PropertyItemLabel>Color</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<ColorPicker
|
||||
value={uppercase(
|
||||
value={capitalizeFirstLetter({ string:
|
||||
(element.color || "FFFFFF").replace("#", "")
|
||||
)}
|
||||
})}
|
||||
onChange={(color) => {
|
||||
updateTextElement(trackId, element.id, {
|
||||
color: `#${color}`,
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { color: `#${color}` },
|
||||
});
|
||||
}}
|
||||
containerRef={containerRef}
|
||||
|
|
@ -292,8 +320,10 @@ export function TextProperties({
|
|||
max={100}
|
||||
step={1}
|
||||
onValueChange={([value]) => {
|
||||
updateTextElement(trackId, element.id, {
|
||||
opacity: value / 100,
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: value / 100 },
|
||||
});
|
||||
setOpacityInput(value.toString());
|
||||
}}
|
||||
|
|
@ -304,12 +334,9 @@ export function TextProperties({
|
|||
value={opacityInput}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => handleOpacityChange(e.target.value)}
|
||||
onChange={(e) => handleOpacityChange({ value: e.target.value })}
|
||||
onBlur={handleOpacityBlur}
|
||||
className="w-12 !text-xs h-7 rounded-sm text-center bg-panel-accent
|
||||
[appearance:textfield]
|
||||
[&::-webkit-outer-spin-button]:appearance-none
|
||||
[&::-webkit-inner-spin-button]:appearance-none"
|
||||
className="bg-panel-accent h-7 w-12 rounded-sm text-center !text-xs [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
|
|
@ -319,19 +346,20 @@ export function TextProperties({
|
|||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<ColorPicker
|
||||
value={uppercase(
|
||||
element.backgroundColor === "transparent"
|
||||
? lastSelectedColor.current.replace("#", "")
|
||||
: (element.backgroundColor || "#000000").replace(
|
||||
"#",
|
||||
""
|
||||
)
|
||||
)}
|
||||
onChange={(color) => handleColorChange(`#${color}`)}
|
||||
value={capitalizeFirstLetter({
|
||||
string:
|
||||
element.backgroundColor === "transparent"
|
||||
? lastSelectedColor.current.replace("#", "")
|
||||
: (element.backgroundColor).replace(
|
||||
"#",
|
||||
"",
|
||||
),
|
||||
})}
|
||||
onChange={(color) => handleColorChange({ color: `#${color}` })}
|
||||
containerRef={containerRef}
|
||||
className={
|
||||
element.backgroundColor === "transparent"
|
||||
? "opacity-50 pointer-events-none"
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
|
|
@ -342,17 +370,17 @@ export function TextProperties({
|
|||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleTransparentToggle(
|
||||
element.backgroundColor !== "transparent"
|
||||
)
|
||||
handleTransparentToggle({
|
||||
isTransparent: element.backgroundColor !== "transparent",
|
||||
})
|
||||
}
|
||||
className="size-9 rounded-full bg-panel-accent p-0 overflow-hidden"
|
||||
className="bg-panel-accent size-9 overflow-hidden rounded-full p-0"
|
||||
>
|
||||
<Grid2x2
|
||||
className={cn(
|
||||
"text-foreground",
|
||||
element.backgroundColor === "transparent" &&
|
||||
"text-primary"
|
||||
"text-primary",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { VideoElement, ImageElement } from "@/types/timeline";
|
||||
|
||||
export function MediaProperties({
|
||||
export function VideoProperties({
|
||||
element,
|
||||
}: {
|
||||
element: VideoElement | ImageElement;
|
||||
}) {
|
||||
return <div className="space-y-4 p-5">Media properties</div>;
|
||||
return <div className="space-y-4 p-5">Video properties</div>;
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@ import {
|
|||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSceneStore } from "@/stores/scene-store";
|
||||
import { Check, ListCheck, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
|
@ -22,26 +21,31 @@ import {
|
|||
DialogFooter,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { canDeleteScene } from "@/lib/scene-utils";
|
||||
import { toast } from "sonner";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
export function ScenesView({ children }: { children: React.ReactNode }) {
|
||||
const { scenes, currentScene, switchToScene, deleteScene } = useSceneStore();
|
||||
const editor = useEditor();
|
||||
const scenes = editor.scenes.getScenes();
|
||||
const currentScene = editor.scenes.getActiveScene();
|
||||
const [isSelectMode, setIsSelectMode] = useState(false);
|
||||
const [selectedScenes, setSelectedScenes] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleSceneSwitch = async (sceneId: string) => {
|
||||
if (isSelectMode) {
|
||||
toggleSceneSelection(sceneId);
|
||||
toggleSceneSelection({ sceneId });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await switchToScene({ sceneId });
|
||||
await editor.scenes.switchToScene({ sceneId });
|
||||
} catch (error) {
|
||||
console.error("Failed to switch scene:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSceneSelection = (sceneId: string) => {
|
||||
const toggleSceneSelection = ({ sceneId }: { sceneId: string }) => {
|
||||
setSelectedScenes((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(sceneId)) {
|
||||
|
|
@ -60,13 +64,21 @@ export function ScenesView({ children }: { children: React.ReactNode }) {
|
|||
|
||||
const handleDeleteSelected = async () => {
|
||||
for (const sceneId of selectedScenes) {
|
||||
const scene = scenes.find((s) => s.id === sceneId);
|
||||
if (scene && !scene.isMain) {
|
||||
try {
|
||||
await deleteScene({ sceneId });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete scene:", error);
|
||||
}
|
||||
const scene = scenes.find((scene) => scene.id === sceneId);
|
||||
if (!scene) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { canDelete, reason } = canDeleteScene({ scene });
|
||||
if (!canDelete) {
|
||||
toast.error(reason || "Failed to delete scene");
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await editor.scenes.deleteScene({ sceneId });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete scene:", error);
|
||||
}
|
||||
}
|
||||
setSelectedScenes(new Set());
|
||||
|
|
@ -87,7 +99,7 @@ export function ScenesView({ children }: { children: React.ReactNode }) {
|
|||
: "Switch between scenes in your project"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="py-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="rounded-md"
|
||||
|
|
@ -103,7 +115,7 @@ export function ScenesView({ children }: { children: React.ReactNode }) {
|
|||
count={selectedScenes.size}
|
||||
onDelete={handleDeleteSelected}
|
||||
disabled={Array.from(selectedScenes).some(
|
||||
(id) => scenes.find((s) => s.id === id)?.isMain
|
||||
(id) => scenes.find((s) => s.id === id)?.isMain,
|
||||
)}
|
||||
>
|
||||
<Button className="rounded-md" variant="destructive" size="sm">
|
||||
|
|
@ -114,7 +126,7 @@ export function ScenesView({ children }: { children: React.ReactNode }) {
|
|||
)}
|
||||
</div>
|
||||
{scenes.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No scenes available
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -130,7 +142,7 @@ export function ScenesView({ children }: { children: React.ReactNode }) {
|
|||
"border-primary !text-primary",
|
||||
isSelectMode &&
|
||||
selectedScenes.has(scene.id) &&
|
||||
"bg-accent border-foreground/30"
|
||||
"bg-accent border-foreground/30",
|
||||
)}
|
||||
onClick={() => handleSceneSwitch(scene.id)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,46 @@ import React, { useEffect, useRef, useState } from "react";
|
|||
import WaveSurfer from "wavesurfer.js";
|
||||
|
||||
interface AudioWaveformProps {
|
||||
audioUrl: string;
|
||||
audioUrl?: string;
|
||||
audioBuffer?: AudioBuffer;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function extractPeaks({
|
||||
buffer,
|
||||
length = 512,
|
||||
}: {
|
||||
buffer: AudioBuffer;
|
||||
length?: number;
|
||||
}): number[][] {
|
||||
const channels = buffer.numberOfChannels;
|
||||
const peaks: number[][] = [];
|
||||
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const data = buffer.getChannelData(c);
|
||||
const step = Math.floor(data.length / length);
|
||||
const channelPeaks: number[] = [];
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const start = i * step;
|
||||
const end = Math.min(start + step, data.length);
|
||||
let max = 0;
|
||||
for (let j = start; j < end; j++) {
|
||||
const abs = Math.abs(data[j]);
|
||||
if (abs > max) max = abs;
|
||||
}
|
||||
channelPeaks.push(max);
|
||||
}
|
||||
peaks.push(channelPeaks);
|
||||
}
|
||||
|
||||
return peaks;
|
||||
}
|
||||
|
||||
const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
||||
audioUrl,
|
||||
audioBuffer,
|
||||
height = 32,
|
||||
className = "",
|
||||
}) => {
|
||||
|
|
@ -22,7 +55,7 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
|||
let ws = wavesurfer.current;
|
||||
|
||||
const initWaveSurfer = async () => {
|
||||
if (!waveformRef.current || !audioUrl) return;
|
||||
if (!waveformRef.current || (!audioUrl && !audioBuffer)) return;
|
||||
|
||||
try {
|
||||
// Clear any existing instance safely
|
||||
|
|
@ -74,7 +107,12 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
|||
}
|
||||
});
|
||||
|
||||
await newWaveSurfer.load(audioUrl);
|
||||
if (audioBuffer) {
|
||||
const peaks = extractPeaks({ buffer: audioBuffer });
|
||||
newWaveSurfer.load("", peaks, audioBuffer.duration);
|
||||
} else if (audioUrl) {
|
||||
await newWaveSurfer.load(audioUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
console.error("Failed to initialize WaveSurfer:", err);
|
||||
|
|
@ -130,7 +168,7 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
|||
});
|
||||
}
|
||||
};
|
||||
}, [audioUrl, height]);
|
||||
}, [audioUrl, audioBuffer, height]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Video, Music, TypeIcon, Eye, VolumeOff, Volume2 } from "lucide-react";
|
||||
import { Eye, VolumeOff, Volume2 } from "lucide-react";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
|
|
@ -11,16 +11,16 @@ import {
|
|||
import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { TimelineTrackContent } from "./timeline-track";
|
||||
import {
|
||||
TimelinePlayhead,
|
||||
useTimelinePlayheadRuler,
|
||||
} from "./timeline-playhead";
|
||||
import { TimelinePlayhead } from "./timeline-playhead";
|
||||
import { SelectionBox } from "../selection-box";
|
||||
import { useSelectionBox } from "@/hooks/use-selection-box";
|
||||
import { SnapIndicator } from "./snap-indicator";
|
||||
import { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import {
|
||||
TIMELINE_CONSTANTS,
|
||||
TRACK_ICONS,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { useElementInteraction } from "@/hooks/timeline/use-element-interaction";
|
||||
import {
|
||||
getTrackHeight,
|
||||
|
|
@ -36,17 +36,16 @@ import { TimelineRuler } from "./timeline-ruler";
|
|||
import { DragLine } from "./drag-line";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||
|
||||
export function Timeline() {
|
||||
const editor = useEditor();
|
||||
const tracks = editor.timeline.sortedTracks;
|
||||
const currentTime = editor.playback.currentTime;
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const seek = (time: number) => editor.playback.seek({ time });
|
||||
|
||||
const { snappingEnabled } = useTimelineStore();
|
||||
const { clearSelection, setSelection } = useElementSelection();
|
||||
const editor = useEditor();
|
||||
const timeline = editor.timeline;
|
||||
const seek = (time: number) => editor.playback.seek({ time });
|
||||
|
||||
// Refs
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const rulerRef = useRef<HTMLDivElement>(null);
|
||||
const tracksContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -56,6 +55,7 @@ export function Timeline() {
|
|||
const playheadRef = useRef<HTMLDivElement>(null);
|
||||
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// State
|
||||
const [isInTimeline, setIsInTimeline] = useState(false);
|
||||
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
|
||||
null,
|
||||
|
|
@ -83,18 +83,18 @@ export function Timeline() {
|
|||
});
|
||||
|
||||
const dynamicTimelineWidth = Math.max(
|
||||
(duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
(currentTime + TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS) *
|
||||
(timeline.getTotalDuration() || 0) *
|
||||
TIMELINE_CONSTANTS.PIXELS_PER_SECOND *
|
||||
zoomLevel,
|
||||
(editor.playback.getCurrentTime() +
|
||||
TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS) *
|
||||
TIMELINE_CONSTANTS.PIXELS_PER_SECOND *
|
||||
zoomLevel,
|
||||
timelineRef.current?.clientWidth || 1000,
|
||||
);
|
||||
|
||||
const { handleRulerMouseDown } = useTimelinePlayheadRuler({
|
||||
currentTime,
|
||||
duration,
|
||||
const { handleRulerMouseDown } = useTimelinePlayhead({
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
|
|
@ -125,11 +125,10 @@ export function Timeline() {
|
|||
const { handleTimelineMouseDown, handleTimelineContentClick } =
|
||||
useTimelineInteractions({
|
||||
playheadRef,
|
||||
tracksContainerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
duration,
|
||||
duration: timeline.getTotalDuration(),
|
||||
isSelecting,
|
||||
justFinishedSelecting,
|
||||
clearSelectedElements: clearSelection,
|
||||
|
|
@ -161,11 +160,7 @@ export function Timeline() {
|
|||
ref={timelineRef}
|
||||
>
|
||||
<TimelinePlayhead
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
zoomLevel={zoomLevel}
|
||||
tracks={tracks}
|
||||
seek={seek}
|
||||
rulerRef={rulerRef}
|
||||
rulerScrollRef={rulerScrollRef}
|
||||
tracksScrollRef={tracksScrollRef}
|
||||
|
|
@ -179,7 +174,7 @@ export function Timeline() {
|
|||
<SnapIndicator
|
||||
snapPoint={currentSnapPoint}
|
||||
zoomLevel={zoomLevel}
|
||||
tracks={tracks}
|
||||
tracks={timeline.getTracks()}
|
||||
timelineRef={timelineRef}
|
||||
trackLabelsRef={trackLabelsRef}
|
||||
tracksScrollRef={tracksScrollRef}
|
||||
|
|
@ -192,7 +187,6 @@ export function Timeline() {
|
|||
|
||||
<TimelineRuler
|
||||
zoomLevel={zoomLevel}
|
||||
duration={duration}
|
||||
dynamicTimelineWidth={dynamicTimelineWidth}
|
||||
rulerRef={rulerRef}
|
||||
rulerScrollRef={rulerScrollRef}
|
||||
|
|
@ -204,7 +198,7 @@ export function Timeline() {
|
|||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{tracks.length > 0 && (
|
||||
{timeline.getTracks().length > 0 && (
|
||||
<div
|
||||
ref={trackLabelsRef}
|
||||
className="z-100 bg-panel w-28 shrink-0 overflow-y-auto border-r"
|
||||
|
|
@ -212,7 +206,7 @@ export function Timeline() {
|
|||
>
|
||||
<ScrollArea className="h-full w-full" ref={trackLabelsScrollRef}>
|
||||
<div className="flex flex-col gap-1">
|
||||
{tracks.map((track) => (
|
||||
{timeline.getTracks().map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="group flex items-center px-3"
|
||||
|
|
@ -225,7 +219,7 @@ export function Timeline() {
|
|||
<VolumeOff
|
||||
className="text-destructive h-4 w-4 cursor-pointer"
|
||||
onClick={() =>
|
||||
editor.timeline.toggleTrackMute({
|
||||
timeline.toggleTrackMute({
|
||||
trackId: track.id,
|
||||
})
|
||||
}
|
||||
|
|
@ -234,7 +228,7 @@ export function Timeline() {
|
|||
<Volume2
|
||||
className="text-muted-foreground h-4 w-4 cursor-pointer"
|
||||
onClick={() =>
|
||||
editor.timeline.toggleTrackMute({
|
||||
timeline.toggleTrackMute({
|
||||
trackId: track.id,
|
||||
})
|
||||
}
|
||||
|
|
@ -273,7 +267,7 @@ export function Timeline() {
|
|||
/>
|
||||
<DragLine
|
||||
dropTarget={dropTarget}
|
||||
tracks={tracks}
|
||||
tracks={timeline.getTracks()}
|
||||
isVisible={isDragOver}
|
||||
/>
|
||||
<ScrollArea className="h-full w-full" ref={tracksScrollRef}>
|
||||
|
|
@ -282,23 +276,26 @@ export function Timeline() {
|
|||
style={{
|
||||
height: `${Math.max(
|
||||
200,
|
||||
Math.min(800, getTotalTracksHeight({ tracks })),
|
||||
Math.min(
|
||||
800,
|
||||
getTotalTracksHeight({ tracks: timeline.getTracks() }),
|
||||
),
|
||||
)}px`,
|
||||
width: `${dynamicTimelineWidth}px`,
|
||||
}}
|
||||
>
|
||||
{tracks.length === 0 ? (
|
||||
{timeline.getTracks().length === 0 ? (
|
||||
<div />
|
||||
) : (
|
||||
<>
|
||||
{tracks.map((track, index) => (
|
||||
{timeline.getTracks().map((track, index) => (
|
||||
<ContextMenu key={track.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className="absolute left-0 right-0"
|
||||
style={{
|
||||
top: `${getCumulativeHeightBefore({
|
||||
tracks,
|
||||
tracks: timeline.getTracks(),
|
||||
trackIndex: index,
|
||||
})}px`,
|
||||
height: `${getTrackHeight({ type: track.type })}px`,
|
||||
|
|
@ -329,7 +326,7 @@ export function Timeline() {
|
|||
<ContextMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
editor.timeline.toggleTrackMute({
|
||||
timeline.toggleTrackMute({
|
||||
trackId: track.id,
|
||||
});
|
||||
}}
|
||||
|
|
@ -354,17 +351,5 @@ export function Timeline() {
|
|||
}
|
||||
|
||||
function TrackIcon({ track }: { track: TimelineTrack }) {
|
||||
return (
|
||||
<>
|
||||
{track.type === "media" && (
|
||||
<Video className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
)}
|
||||
{track.type === "text" && (
|
||||
<TypeIcon className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
)}
|
||||
{track.type === "audio" && (
|
||||
<Music className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return <>{TRACK_ICONS[track.type]}</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface SnapIndicatorProps {
|
||||
snapPoint: SnapPoint | null;
|
||||
|
|
@ -24,50 +23,26 @@ export function SnapIndicator({
|
|||
trackLabelsRef,
|
||||
tracksScrollRef,
|
||||
}: SnapIndicatorProps) {
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
|
||||
// Track scroll position to lock snap indicator to frame
|
||||
useEffect(() => {
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
|
||||
if (!tracksViewport) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollLeft(tracksViewport.scrollLeft);
|
||||
};
|
||||
|
||||
// Set initial scroll position
|
||||
setScrollLeft(tracksViewport.scrollLeft);
|
||||
|
||||
tracksViewport.addEventListener("scroll", handleScroll);
|
||||
return () => tracksViewport.removeEventListener("scroll", handleScroll);
|
||||
}, [tracksScrollRef]);
|
||||
const { leftPosition, topPosition, height } = useSnapIndicatorPosition({
|
||||
snapPoint,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
timelineRef,
|
||||
trackLabelsRef,
|
||||
tracksScrollRef,
|
||||
});
|
||||
|
||||
if (!isVisible || !snapPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
|
||||
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
|
||||
|
||||
// Get dynamic track labels width, fallback to 0 if no tracks or no ref
|
||||
const trackLabelsWidth =
|
||||
tracks.length > 0 && trackLabelsRef?.current
|
||||
? trackLabelsRef.current.offsetWidth
|
||||
: 0;
|
||||
|
||||
// Calculate position locked to timeline content (accounting for scroll)
|
||||
const timelinePosition =
|
||||
snapPoint.time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="z-90 pointer-events-none absolute"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
height: `${totalHeight}px`,
|
||||
top: topPosition,
|
||||
height: `${height}px`,
|
||||
width: "2px",
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -12,16 +12,18 @@ import {
|
|||
VolumeX,
|
||||
ArrowUpDown,
|
||||
} from "lucide-react";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||
import AudioWaveform from "./audio-waveform";
|
||||
import { useTimelineElementResize } from "@/hooks/timeline/use-element-resize";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import {
|
||||
getTrackColor,
|
||||
getTrackClasses,
|
||||
getTrackHeight,
|
||||
isMutableElement,
|
||||
canHaveAudio,
|
||||
canBeHidden,
|
||||
hasMediaId,
|
||||
} from "@/lib/timeline";
|
||||
import {
|
||||
ContextMenu,
|
||||
|
|
@ -30,12 +32,14 @@ import {
|
|||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "../../ui/context-menu";
|
||||
import { useAssetsPanelStore } from "../../../stores/assets-panel-store";
|
||||
import {
|
||||
import type {
|
||||
TimelineElement as TimelineElementType,
|
||||
TimelineTrack,
|
||||
ElementDragState,
|
||||
} from "@/types/timeline";
|
||||
import { ElementDragState } from "@/types/timeline";
|
||||
import { MediaAsset } from "@/types/assets";
|
||||
import { mediaSupportsAudio } from "@/lib/media-utils";
|
||||
import { type TAction, invokeAction } from "@/lib/actions";
|
||||
|
||||
interface TimelineElementProps {
|
||||
element: TimelineElementType;
|
||||
|
|
@ -59,25 +63,19 @@ export function TimelineElement({
|
|||
onElementClick,
|
||||
dragState,
|
||||
}: TimelineElementProps) {
|
||||
const { mediaFiles } = useMediaStore();
|
||||
const editor = useEditor();
|
||||
const { selectedElements } = useTimelineStore();
|
||||
const { requestRevealMedia } = useAssetsPanelStore();
|
||||
const {
|
||||
copySelected,
|
||||
selectedElements,
|
||||
deleteSelected,
|
||||
splitSelected,
|
||||
toggleSelectedHidden,
|
||||
toggleSelectedMuted,
|
||||
duplicateElement,
|
||||
getContextMenuState,
|
||||
} = useTimelineStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
|
||||
const mediaItem =
|
||||
element.type === "media"
|
||||
? mediaFiles.find((file) => file.id === element.mediaId)
|
||||
: null;
|
||||
const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video";
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
let mediaAsset: MediaAsset | null = null;
|
||||
|
||||
if (hasMediaId(element)) {
|
||||
mediaAsset =
|
||||
mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
|
||||
}
|
||||
|
||||
const hasAudio = mediaSupportsAudio({ media: mediaAsset });
|
||||
|
||||
const { handleResizeStart } = useTimelineElementResize({
|
||||
element,
|
||||
|
|
@ -85,18 +83,14 @@ export function TimelineElement({
|
|||
zoomLevel,
|
||||
});
|
||||
|
||||
const {
|
||||
isMultipleSelected,
|
||||
isCurrentElementSelected,
|
||||
hasAudioElements,
|
||||
canSplitSelected,
|
||||
} = getContextMenuState(track.id, element.id);
|
||||
const isCurrentElementSelected = selectedElements.some(
|
||||
(selected) =>
|
||||
selected.elementId === element.id && selected.trackId === track.id,
|
||||
);
|
||||
|
||||
const effectiveDuration =
|
||||
element.duration - element.trimStart - element.trimEnd;
|
||||
const elementWidth = Math.max(
|
||||
TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
|
||||
effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
element.duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
);
|
||||
|
||||
const isBeingDragged = dragState.elementId === element.id;
|
||||
|
|
@ -104,116 +98,266 @@ export function TimelineElement({
|
|||
isBeingDragged && dragState.isDragging
|
||||
? dragState.currentTime
|
||||
: element.startTime;
|
||||
|
||||
const elementLeft = elementStartTime * 50 * zoomLevel;
|
||||
|
||||
const handleElementSplitContext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
splitSelected(
|
||||
currentTime,
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : element.id,
|
||||
);
|
||||
const handleAction = ({
|
||||
action,
|
||||
event,
|
||||
}: {
|
||||
action: TAction;
|
||||
event: React.MouseEvent;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
invokeAction(action);
|
||||
};
|
||||
|
||||
const handleElementDuplicateContext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
duplicateElement(track.id, element.id);
|
||||
};
|
||||
|
||||
const handleElementCopyContext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
copySelected();
|
||||
};
|
||||
|
||||
const handleElementDeleteContext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
deleteSelected(
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : element.id,
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleElementContext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (hasAudio && element.type === "media") {
|
||||
toggleSelectedMuted(
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : element.id,
|
||||
);
|
||||
} else {
|
||||
toggleSelectedHidden(
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : element.id,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevealInMedia = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (element.type === "media") {
|
||||
const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
|
||||
event.stopPropagation();
|
||||
if (hasMediaId(element)) {
|
||||
requestRevealMedia(element.mediaId);
|
||||
}
|
||||
};
|
||||
|
||||
const renderElementContent = () => {
|
||||
if (element.type === "text") {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-start pl-2">
|
||||
<span className="truncate text-xs text-white">{element.content}</span>
|
||||
const isMuted = canHaveAudio(element) && element.muted === true;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={`timeline-element absolute top-0 h-full select-none ${
|
||||
isBeingDragged ? "z-50" : "z-10"
|
||||
}`}
|
||||
style={{ left: `${elementLeft}px`, width: `${elementWidth}px` }}
|
||||
data-element-id={element.id}
|
||||
data-track-id={track.id}
|
||||
>
|
||||
<ElementInner
|
||||
element={element}
|
||||
track={track}
|
||||
isSelected={isSelected}
|
||||
isBeingDragged={isBeingDragged}
|
||||
hasAudio={hasAudio}
|
||||
isMuted={isMuted}
|
||||
mediaAssets={mediaAssets}
|
||||
onElementClick={onElementClick}
|
||||
onElementMouseDown={onElementMouseDown}
|
||||
handleResizeStart={handleResizeStart}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mediaItem = mediaFiles.find((file) => file.id === element.mediaId);
|
||||
if (!mediaItem) {
|
||||
return (
|
||||
<span className="text-foreground/80 truncate text-xs">
|
||||
{element.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
mediaItem.type === "image" ||
|
||||
(mediaItem.type === "video" && mediaItem.thumbnailUrl)
|
||||
) {
|
||||
const trackHeight = getTrackHeight({ type: track.type });
|
||||
const tileWidth = trackHeight * (16 / 9);
|
||||
|
||||
const imageUrl =
|
||||
mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div
|
||||
className={`relative h-full w-full ${
|
||||
isSelected ? "bg-primary" : "bg-transparent"
|
||||
}`}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="z-200">
|
||||
<ContextMenuItem
|
||||
onClick={(event) => handleAction({ action: "split-selected", event })}
|
||||
>
|
||||
<Scissors className="mr-2 size-4" />
|
||||
{selectedElements.length > 1 && isCurrentElementSelected
|
||||
? `Split ${selectedElements.length} elements at playhead`
|
||||
: "Split at playhead"}
|
||||
</ContextMenuItem>
|
||||
<CopyMenuItem
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
isCurrentElementSelected={isCurrentElementSelected}
|
||||
selectedCount={selectedElements.length}
|
||||
onClick={(event) => handleAction({ action: "copy-selected", event })}
|
||||
/>
|
||||
{canHaveAudio(element) && hasAudio && (
|
||||
<MuteMenuItem
|
||||
element={element}
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
isCurrentElementSelected={isCurrentElementSelected}
|
||||
isMuted={isMuted}
|
||||
selectedCount={selectedElements.length}
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "toggle-mute-selected", event })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{canBeHidden(element) && (
|
||||
<VisibilityMenuItem
|
||||
element={element}
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
isCurrentElementSelected={isCurrentElementSelected}
|
||||
selectedCount={selectedElements.length}
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "toggle-visibility-selected", event })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{selectedElements.length === 1 && (
|
||||
<ContextMenuItem
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "duplicate-selected", event })
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`absolute bottom-[0.25rem] left-0 right-0 top-[0.25rem]`}
|
||||
style={{
|
||||
backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
|
||||
backgroundRepeat: "repeat-x",
|
||||
backgroundSize: `${tileWidth}px ${trackHeight}px`,
|
||||
backgroundPosition: "left center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
aria-label={`Tiled ${mediaItem.type === "image" ? "background" : "thumbnail"} of ${mediaItem.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<Copy className="mr-2 size-4" />
|
||||
Duplicate {element.type === "text" ? "text" : "clip"}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem disabled>
|
||||
<ArrowUpDown className="mr-2 size-4" />
|
||||
Move to track (Coming soon)
|
||||
</ContextMenuItem>
|
||||
{selectedElements.length === 1 && hasMediaId(element) && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
onClick={(event) => handleRevealInMedia({ event })}
|
||||
>
|
||||
<Search className="mr-2 size-4" />
|
||||
Reveal in media
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled>
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
Replace clip (Coming soon)
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<DeleteMenuItem
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
isCurrentElementSelected={isCurrentElementSelected}
|
||||
elementType={element.type}
|
||||
selectedCount={selectedElements.length}
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "delete-selected", event })
|
||||
}
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaItem.type === "audio") {
|
||||
function ElementInner({
|
||||
element,
|
||||
track,
|
||||
isSelected,
|
||||
isBeingDragged,
|
||||
hasAudio,
|
||||
isMuted,
|
||||
mediaAssets,
|
||||
onElementClick,
|
||||
onElementMouseDown,
|
||||
handleResizeStart,
|
||||
}: {
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
isSelected: boolean;
|
||||
isBeingDragged: boolean;
|
||||
hasAudio: boolean;
|
||||
isMuted: boolean;
|
||||
mediaAssets: MediaAsset[];
|
||||
onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
|
||||
onElementMouseDown: (
|
||||
e: React.MouseEvent,
|
||||
element: TimelineElementType,
|
||||
) => void;
|
||||
handleResizeStart: (params: {
|
||||
e: React.MouseEvent;
|
||||
elementId: string;
|
||||
side: "left" | "right";
|
||||
}) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses(
|
||||
{
|
||||
type: track.type,
|
||||
},
|
||||
)} ${isBeingDragged ? "z-50" : "z-10"} ${canBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
|
||||
onClick={(e) => onElementClick(e, element)}
|
||||
onMouseDown={(e) => onElementMouseDown(e, element)}
|
||||
onContextMenu={(e) => onElementMouseDown(e, element)}
|
||||
>
|
||||
<div className="absolute inset-0 flex h-full items-center">
|
||||
<ElementContent
|
||||
element={element}
|
||||
track={track}
|
||||
isSelected={isSelected}
|
||||
mediaAssets={mediaAssets}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(hasAudio ? isMuted : canBeHidden(element) && element.hidden) && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black bg-opacity-50">
|
||||
{hasAudio ? (
|
||||
<VolumeX className="size-6 text-white" />
|
||||
) : (
|
||||
<EyeOff className="size-6 text-white" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSelected && (
|
||||
<>
|
||||
<div
|
||||
className="bg-primary absolute bottom-0 left-0 top-0 z-50 flex w-[0.6rem] cursor-w-resize items-center justify-center"
|
||||
onMouseDown={(e) =>
|
||||
handleResizeStart({ e, elementId: element.id, side: "left" })
|
||||
}
|
||||
>
|
||||
<div className="bg-foreground/75 h-[1.5rem] w-[0.2rem] rounded-full" />
|
||||
</div>
|
||||
<div
|
||||
className="bg-primary absolute bottom-0 right-0 top-0 z-50 flex w-[0.6rem] cursor-e-resize items-center justify-center"
|
||||
onMouseDown={(e) =>
|
||||
handleResizeStart({ e, elementId: element.id, side: "right" })
|
||||
}
|
||||
>
|
||||
<div className="bg-foreground/75 h-[1.5rem] w-[0.2rem] rounded-full" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ElementContent({
|
||||
element,
|
||||
track,
|
||||
isSelected,
|
||||
mediaAssets,
|
||||
}: {
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
isSelected: boolean;
|
||||
mediaAssets: MediaAsset[];
|
||||
}) {
|
||||
if (element.type === "text") {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-start pl-2">
|
||||
<span className="truncate text-xs text-white">{element.content}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === "sticker") {
|
||||
return (
|
||||
<div className="flex size-full items-center gap-2 pl-2">
|
||||
<img
|
||||
src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`}
|
||||
alt={element.name}
|
||||
className="size-5 shrink-0"
|
||||
/>
|
||||
<span className="truncate text-xs text-white">{element.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === "audio") {
|
||||
const audioBuffer =
|
||||
element.sourceType === "library" ? element.buffer : undefined;
|
||||
|
||||
const audioUrl =
|
||||
element.sourceType === "upload"
|
||||
? mediaAssets.find((asset) => asset.id === element.mediaId)?.url
|
||||
: undefined;
|
||||
|
||||
if (audioBuffer || audioUrl) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center gap-2">
|
||||
<div className="flex size-full items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<AudioWaveform
|
||||
audioUrl={mediaItem.url || ""}
|
||||
audioBuffer={audioBuffer}
|
||||
audioUrl={audioUrl}
|
||||
height={24}
|
||||
className="w-full"
|
||||
/>
|
||||
|
|
@ -227,180 +371,179 @@ export function TimelineElement({
|
|||
{element.name}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const handleElementMouseDown = (e: React.MouseEvent) => {
|
||||
if (onElementMouseDown) {
|
||||
onElementMouseDown(e, element);
|
||||
}
|
||||
};
|
||||
const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
|
||||
if (!mediaAsset) {
|
||||
return (
|
||||
<span className="text-foreground/80 truncate text-xs">
|
||||
{element.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const isMuted = isMutableElement(element) && element.muted;
|
||||
if (
|
||||
mediaAsset.type === "image" ||
|
||||
(mediaAsset.type === "video" && mediaAsset.thumbnailUrl)
|
||||
) {
|
||||
const trackHeight = getTrackHeight({ type: track.type });
|
||||
const tileWidth = trackHeight * (16 / 9);
|
||||
const imageUrl =
|
||||
mediaAsset.type === "image" ? mediaAsset.url : mediaAsset.thumbnailUrl;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<div
|
||||
className={`timeline-element absolute top-0 h-full select-none ${
|
||||
isBeingDragged ? "z-50" : "z-10"
|
||||
}`}
|
||||
style={{
|
||||
left: `${elementLeft}px`,
|
||||
width: `${elementWidth}px`,
|
||||
}}
|
||||
data-element-id={element.id}
|
||||
data-track-id={track.id}
|
||||
className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`}
|
||||
>
|
||||
<div
|
||||
className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackColor(
|
||||
{
|
||||
type: track.type,
|
||||
},
|
||||
)} ${isSelected ? "" : ""} ${
|
||||
isBeingDragged ? "z-50" : "z-10"
|
||||
} ${element.hidden ? "opacity-50" : ""}`}
|
||||
onClick={(e) => onElementClick && onElementClick(e, element)}
|
||||
onMouseDown={handleElementMouseDown}
|
||||
onContextMenu={(e) =>
|
||||
onElementMouseDown && onElementMouseDown(e, element)
|
||||
}
|
||||
>
|
||||
<div className="absolute inset-0 flex h-full items-center">
|
||||
{renderElementContent()}
|
||||
</div>
|
||||
|
||||
{(hasAudio ? isMuted : element.hidden) && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black bg-opacity-50">
|
||||
{hasAudio ? (
|
||||
<VolumeX className="h-6 w-6 text-white" />
|
||||
) : (
|
||||
<EyeOff className="h-6 w-6 text-white" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSelected && (
|
||||
<>
|
||||
<div
|
||||
className="bg-primary absolute bottom-0 left-0 top-0 z-50 flex w-[0.6rem] cursor-w-resize items-center justify-center"
|
||||
onMouseDown={(e) =>
|
||||
handleResizeStart({
|
||||
e,
|
||||
elementId: element.id,
|
||||
side: "left",
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="bg-foreground/75 h-[1.5rem] w-[0.2rem] rounded-full" />
|
||||
</div>
|
||||
<div
|
||||
className="bg-primary absolute bottom-0 right-0 top-0 z-50 flex w-[0.6rem] cursor-e-resize items-center justify-center"
|
||||
onMouseDown={(e) =>
|
||||
handleResizeStart({
|
||||
e,
|
||||
elementId: element.id,
|
||||
side: "right",
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="bg-foreground/75 h-[1.5rem] w-[0.2rem] rounded-full" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
className="absolute bottom-[0.25rem] left-0 right-0 top-[0.25rem]"
|
||||
style={{
|
||||
backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
|
||||
backgroundRepeat: "repeat-x",
|
||||
backgroundSize: `${tileWidth}px ${trackHeight}px`,
|
||||
backgroundPosition: "left center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
aria-label={`Tiled ${mediaAsset.type === "image" ? "background" : "thumbnail"} of ${mediaAsset.name}`}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="z-200">
|
||||
{(!isMultipleSelected ||
|
||||
(isMultipleSelected &&
|
||||
isCurrentElementSelected &&
|
||||
canSplitSelected)) && (
|
||||
<ContextMenuItem onClick={handleElementSplitContext}>
|
||||
<Scissors className="mr-2 h-4 w-4" />
|
||||
{isMultipleSelected && isCurrentElementSelected
|
||||
? `Split ${selectedElements.length} elements at playhead`
|
||||
: "Split at playhead"}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<ContextMenuItem onClick={handleElementCopyContext}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
{isMultipleSelected && isCurrentElementSelected
|
||||
? `Copy ${selectedElements.length} elements`
|
||||
: "Copy element"}
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem onClick={handleToggleElementContext}>
|
||||
{isMultipleSelected && isCurrentElementSelected ? (
|
||||
hasAudioElements ? (
|
||||
<VolumeX className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<EyeOff className="mr-2 h-4 w-4" />
|
||||
)
|
||||
) : hasAudio ? (
|
||||
isMuted ? (
|
||||
<Volume2 className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<VolumeX className="mr-2 h-4 w-4" />
|
||||
)
|
||||
) : element.hidden ? (
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<EyeOff className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<span>
|
||||
{isMultipleSelected && isCurrentElementSelected
|
||||
? hasAudioElements
|
||||
? `Toggle mute ${selectedElements.length} elements`
|
||||
: `Toggle visibility ${selectedElements.length} elements`
|
||||
: hasAudio
|
||||
? isMuted
|
||||
? "Unmute"
|
||||
: "Mute"
|
||||
: element.hidden
|
||||
? "Show"
|
||||
: "Hide"}{" "}
|
||||
{!isMultipleSelected && (element.type === "text" ? "text" : "clip")}
|
||||
</span>
|
||||
</ContextMenuItem>
|
||||
|
||||
{!isMultipleSelected && (
|
||||
<ContextMenuItem onClick={handleElementDuplicateContext}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate {element.type === "text" ? "text" : "clip"}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
<ContextMenuItem disabled>
|
||||
<ArrowUpDown className="mr-2 h-4 w-4" />
|
||||
Move to track (Coming soon)
|
||||
</ContextMenuItem>
|
||||
|
||||
{!isMultipleSelected && element.type === "media" && (
|
||||
<>
|
||||
<ContextMenuItem onClick={handleRevealInMedia}>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
Reveal in media
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Replace clip (Coming soon)
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem
|
||||
onClick={handleElementDeleteContext}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{isMultipleSelected && isCurrentElementSelected
|
||||
? `Delete ${selectedElements.length} elements`
|
||||
: `Delete ${element.type === "text" ? "text" : "clip"}`}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
return (
|
||||
<span className="text-foreground/80 truncate text-xs">{element.name}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyMenuItem({
|
||||
isMultipleSelected,
|
||||
isCurrentElementSelected,
|
||||
selectedCount,
|
||||
onClick,
|
||||
}: {
|
||||
isMultipleSelected: boolean;
|
||||
isCurrentElementSelected: boolean;
|
||||
selectedCount: number;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuItem onClick={onClick}>
|
||||
<Copy className="mr-2 size-4" />
|
||||
{isMultipleSelected && isCurrentElementSelected
|
||||
? `Copy ${selectedCount} elements`
|
||||
: "Copy element"}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function MuteMenuItem({
|
||||
element,
|
||||
isMultipleSelected,
|
||||
isCurrentElementSelected,
|
||||
isMuted,
|
||||
selectedCount,
|
||||
onClick,
|
||||
}: {
|
||||
element: TimelineElementType;
|
||||
isMultipleSelected: boolean;
|
||||
isCurrentElementSelected: boolean;
|
||||
isMuted: boolean;
|
||||
selectedCount: number;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
const getIcon = () => {
|
||||
if (isMultipleSelected && isCurrentElementSelected) {
|
||||
return <VolumeX className="mr-2 size-4" />;
|
||||
}
|
||||
return isMuted ? (
|
||||
<Volume2 className="mr-2 size-4" />
|
||||
) : (
|
||||
<VolumeX className="mr-2 size-4" />
|
||||
);
|
||||
};
|
||||
|
||||
const getLabel = () => {
|
||||
if (isMultipleSelected && isCurrentElementSelected) {
|
||||
return `Toggle mute ${selectedCount} elements`;
|
||||
}
|
||||
const suffix = element.type === "text" ? "text" : "clip";
|
||||
return isMuted ? `Unmute ${suffix}` : `Mute ${suffix}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenuItem onClick={onClick}>
|
||||
{getIcon()}
|
||||
<span>{getLabel()}</span>
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function VisibilityMenuItem({
|
||||
element,
|
||||
isMultipleSelected,
|
||||
isCurrentElementSelected,
|
||||
selectedCount,
|
||||
onClick,
|
||||
}: {
|
||||
element: TimelineElementType;
|
||||
isMultipleSelected: boolean;
|
||||
isCurrentElementSelected: boolean;
|
||||
selectedCount: number;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
const isHidden = canBeHidden(element) && element.hidden;
|
||||
|
||||
const getIcon = () => {
|
||||
if (isMultipleSelected && isCurrentElementSelected) {
|
||||
return <EyeOff className="mr-2 size-4" />;
|
||||
}
|
||||
return isHidden ? (
|
||||
<Eye className="mr-2 size-4" />
|
||||
) : (
|
||||
<EyeOff className="mr-2 size-4" />
|
||||
);
|
||||
};
|
||||
|
||||
const getLabel = () => {
|
||||
if (isMultipleSelected && isCurrentElementSelected) {
|
||||
return `Toggle visibility ${selectedCount} elements`;
|
||||
}
|
||||
const suffix = element.type === "text" ? "text" : "clip";
|
||||
return isHidden ? `Show ${suffix}` : `Hide ${suffix}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenuItem onClick={onClick}>
|
||||
{getIcon()}
|
||||
<span>{getLabel()}</span>
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteMenuItem({
|
||||
isMultipleSelected,
|
||||
isCurrentElementSelected,
|
||||
elementType,
|
||||
selectedCount,
|
||||
onClick,
|
||||
}: {
|
||||
isMultipleSelected: boolean;
|
||||
isCurrentElementSelected: boolean;
|
||||
elementType: TimelineElementType["type"];
|
||||
selectedCount: number;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuItem
|
||||
onClick={onClick}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
{isMultipleSelected && isCurrentElementSelected
|
||||
? `Delete ${selectedCount} elements`
|
||||
: `Delete ${elementType === "text" ? "text" : "clip"}`}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
||||
interface TimelineMarkerProps {
|
||||
time: number;
|
||||
zoomLevel: number;
|
||||
interval: number;
|
||||
isMainMarker: boolean;
|
||||
}
|
||||
|
||||
export function TimelineMarker({
|
||||
time,
|
||||
zoomLevel,
|
||||
interval,
|
||||
isMainMarker,
|
||||
}: TimelineMarkerProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 h-4",
|
||||
isMainMarker
|
||||
? "border-l border-muted-foreground/40"
|
||||
: "border-l border-muted-foreground/20"
|
||||
)}
|
||||
style={{
|
||||
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-1 left-1 text-[0.6rem]",
|
||||
isMainMarker
|
||||
? "text-muted-foreground font-medium"
|
||||
: "text-muted-foreground/70"
|
||||
)}
|
||||
>
|
||||
{(() => {
|
||||
const formatTime = (seconds: number) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}:${Math.floor(secs)
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}:${Math.floor(secs)
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
}
|
||||
if (interval >= 1) {
|
||||
return `${Math.floor(secs)}s`;
|
||||
}
|
||||
return `${secs.toFixed(1)}s`;
|
||||
};
|
||||
return formatTime(time);
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
interface TimelinePlayheadProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
zoomLevel: number;
|
||||
tracks: TimelineTrack[];
|
||||
seek: (time: number) => void;
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
|
|
@ -21,11 +17,7 @@ interface TimelinePlayheadProps {
|
|||
}
|
||||
|
||||
export function TimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
|
|
@ -34,22 +26,21 @@ export function TimelinePlayhead({
|
|||
playheadRef: externalPlayheadRef,
|
||||
isSnappingToPlayhead = false,
|
||||
}: TimelinePlayheadProps) {
|
||||
const editor = useEditor();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const internalPlayheadRef = useRef<HTMLDivElement>(null);
|
||||
const playheadRef = externalPlayheadRef || internalPlayheadRef;
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
|
||||
const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
});
|
||||
|
||||
// Track scroll position to lock playhead to frame
|
||||
useEffect(() => {
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
|
||||
|
|
@ -59,39 +50,33 @@ export function TimelinePlayhead({
|
|||
setScrollLeft(tracksViewport.scrollLeft);
|
||||
};
|
||||
|
||||
// Set initial scroll position
|
||||
setScrollLeft(tracksViewport.scrollLeft);
|
||||
|
||||
tracksViewport.addEventListener("scroll", handleScroll);
|
||||
return () => tracksViewport.removeEventListener("scroll", handleScroll);
|
||||
}, [tracksScrollRef]);
|
||||
|
||||
// Use timeline container height minus a few pixels for breathing room
|
||||
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
|
||||
const totalHeight = timelineContainerHeight - 4;
|
||||
|
||||
// Get dynamic track labels width, fallback to 0 if no tracks or no ref
|
||||
const trackLabelsWidth =
|
||||
tracks.length > 0 && trackLabelsRef?.current
|
||||
? trackLabelsRef.current.offsetWidth
|
||||
: 0;
|
||||
|
||||
// Calculate position locked to timeline content (accounting for scroll)
|
||||
const timelinePosition =
|
||||
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const rawLeftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
|
||||
|
||||
// Get the timeline content width and viewport width for right boundary
|
||||
const timelineContentWidth =
|
||||
duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
const viewportWidth = tracksViewport?.clientWidth || 1000;
|
||||
|
||||
// Constrain playhead to never appear outside the timeline area
|
||||
const leftBoundary = trackLabelsWidth;
|
||||
const rightBoundary = Math.min(
|
||||
trackLabelsWidth + timelineContentWidth - scrollLeft, // Don't go beyond timeline content
|
||||
trackLabelsWidth + viewportWidth, // Don't go beyond viewport
|
||||
trackLabelsWidth + timelineContentWidth - scrollLeft,
|
||||
trackLabelsWidth + viewportWidth,
|
||||
);
|
||||
|
||||
const leftPosition = Math.max(
|
||||
|
|
@ -99,26 +84,6 @@ export function TimelinePlayhead({
|
|||
Math.min(rightBoundary, rawLeftPosition),
|
||||
);
|
||||
|
||||
// Debug logging when playhead might go outside
|
||||
if (rawLeftPosition < leftBoundary || rawLeftPosition > rightBoundary) {
|
||||
console.log(
|
||||
"PLAYHEAD VISUAL DEBUG:",
|
||||
JSON.stringify({
|
||||
playheadPosition,
|
||||
timelinePosition,
|
||||
trackLabelsWidth,
|
||||
scrollLeft,
|
||||
rawLeftPosition,
|
||||
constrainedLeftPosition: leftPosition,
|
||||
leftBoundary,
|
||||
rightBoundary,
|
||||
timelineContentWidth,
|
||||
viewportWidth,
|
||||
zoomLevel,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={playheadRef}
|
||||
|
|
@ -127,46 +92,15 @@ export function TimelinePlayhead({
|
|||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
height: `${totalHeight}px`,
|
||||
width: "2px", // Slightly wider for better click target
|
||||
width: "2px",
|
||||
}}
|
||||
onMouseDown={handlePlayheadMouseDown}
|
||||
>
|
||||
{/* The playhead line spanning full height */}
|
||||
<div
|
||||
className={`absolute left-0 h-full w-0.5 cursor-col-resize ${isSnappingToPlayhead ? "bg-foreground" : "bg-foreground"}`}
|
||||
/>
|
||||
<div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" />
|
||||
|
||||
{/* Playhead dot indicator at the top (in ruler area) */}
|
||||
<div
|
||||
className={`shadow-xs absolute left-1/2 top-1 h-3 w-3 -translate-x-1/2 transform rounded-full border-2 ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Also export a hook for getting ruler handlers
|
||||
export function useTimelinePlayheadRuler({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
|
||||
const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
});
|
||||
|
||||
return { handleRulerMouseDown, isDraggingRuler };
|
||||
}
|
||||
|
||||
export { TimelinePlayhead as default };
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { TimelineMarker } from "./timeline-marker";
|
||||
import { useSceneStore } from "@/stores/scene-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { Bookmark } from "lucide-react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useCallback } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { Bookmark } from "lucide-react";
|
||||
import { TimelineTick } from "./timeline-tick";
|
||||
|
||||
interface TimelineRulerProps {
|
||||
zoomLevel: number;
|
||||
duration: number;
|
||||
dynamicTimelineWidth: number;
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
|
|
@ -20,7 +17,6 @@ interface TimelineRulerProps {
|
|||
|
||||
export function TimelineRuler({
|
||||
zoomLevel,
|
||||
duration,
|
||||
dynamicTimelineWidth,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
|
|
@ -29,18 +25,35 @@ export function TimelineRuler({
|
|||
handleTimelineContentClick,
|
||||
handleRulerMouseDown,
|
||||
}: TimelineRulerProps) {
|
||||
const { activeScene } = useSceneStore();
|
||||
const editor = useEditor();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
|
||||
const getOptimalTimeInterval = useCallback((zoom: number) => {
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom;
|
||||
if (pixelsPerSecond >= 200) return 0.1;
|
||||
if (pixelsPerSecond >= 100) return 0.5;
|
||||
if (pixelsPerSecond >= 50) return 1;
|
||||
if (pixelsPerSecond >= 25) return 2;
|
||||
if (pixelsPerSecond >= 12) return 5;
|
||||
if (pixelsPerSecond >= 6) return 10;
|
||||
return 30;
|
||||
}, []);
|
||||
const interval = getOptimalTimeInterval({ zoomLevel });
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const tickSpacingPixels = interval * pixelsPerSecond;
|
||||
const minLabelSpacingPixels = 120;
|
||||
const labelEvery = Math.max(
|
||||
1,
|
||||
Math.ceil(minLabelSpacingPixels / tickSpacingPixels),
|
||||
);
|
||||
const markerCount = Math.ceil(duration / interval) + 1;
|
||||
|
||||
const timelineTicks: Array<JSX.Element> = [];
|
||||
for (let markerIndex = 0; markerIndex < markerCount; markerIndex += 1) {
|
||||
const time = markerIndex * interval;
|
||||
if (time > duration) break;
|
||||
|
||||
timelineTicks.push(
|
||||
<TimelineTick
|
||||
key={markerIndex}
|
||||
time={time}
|
||||
zoomLevel={zoomLevel}
|
||||
interval={interval}
|
||||
shouldShowLabel={markerIndex % labelEvery === 0}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -59,31 +72,11 @@ export function TimelineRuler({
|
|||
}}
|
||||
onMouseDown={handleRulerMouseDown}
|
||||
>
|
||||
{(() => {
|
||||
const interval = getOptimalTimeInterval(zoomLevel);
|
||||
const markerCount = Math.ceil(duration / interval) + 1;
|
||||
{timelineTicks}
|
||||
|
||||
return Array.from({ length: markerCount }, (_, i) => {
|
||||
const time = i * interval;
|
||||
if (time > duration) return null;
|
||||
|
||||
const isMainMarker = time % Math.max(1, interval) === 0;
|
||||
|
||||
return (
|
||||
<TimelineMarker
|
||||
key={i}
|
||||
time={time}
|
||||
zoomLevel={zoomLevel}
|
||||
interval={interval}
|
||||
isMainMarker={isMainMarker}
|
||||
/>
|
||||
);
|
||||
}).filter(Boolean);
|
||||
})()}
|
||||
|
||||
{activeScene?.timeline?.bookmarks?.map((time, i) => (
|
||||
{activeScene.bookmarks.map((time: number, index: number) => (
|
||||
<TimelineBookmark
|
||||
key={`bookmark-${i}`}
|
||||
key={`bookmark-${index}`}
|
||||
time={time}
|
||||
zoomLevel={zoomLevel}
|
||||
/>
|
||||
|
|
@ -101,7 +94,16 @@ function TimelineBookmark({
|
|||
time: number;
|
||||
zoomLevel: number;
|
||||
}) {
|
||||
const seek = usePlaybackStore((state) => state.seek);
|
||||
const editor = useEditor();
|
||||
|
||||
const handleBookmarkClick = ({
|
||||
event,
|
||||
}: {
|
||||
event: React.MouseEvent<HTMLDivElement>;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
editor.playback.seek({ time });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -109,14 +111,22 @@ function TimelineBookmark({
|
|||
style={{
|
||||
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
seek(time);
|
||||
}}
|
||||
onClick={(event) => handleBookmarkClick({ event })}
|
||||
>
|
||||
<div className="text-primary absolute left-[-5px] top-[-1px]">
|
||||
<Bookmark className="fill-primary h-3 w-3" />
|
||||
<Bookmark className="fill-primary size-3" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getOptimalTimeInterval({ zoomLevel }: { zoomLevel: number }) {
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
if (pixelsPerSecond >= 200) return 0.1;
|
||||
if (pixelsPerSecond >= 100) return 0.5;
|
||||
if (pixelsPerSecond >= 50) return 1;
|
||||
if (pixelsPerSecond >= 25) return 2;
|
||||
if (pixelsPerSecond >= 12) return 5;
|
||||
if (pixelsPerSecond >= 6) return 10;
|
||||
return 30;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
"use client";
|
||||
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
||||
interface TimelineTickProps {
|
||||
time: number;
|
||||
zoomLevel: number;
|
||||
interval: number;
|
||||
shouldShowLabel: boolean;
|
||||
}
|
||||
|
||||
export function TimelineTick({
|
||||
time,
|
||||
zoomLevel,
|
||||
interval,
|
||||
shouldShowLabel,
|
||||
}: TimelineTickProps) {
|
||||
return (
|
||||
<div
|
||||
className="border-muted-foreground/20 absolute top-0 h-4 border-l"
|
||||
style={{
|
||||
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
||||
}}
|
||||
>
|
||||
{shouldShowLabel ? (
|
||||
<span className="text-muted-foreground/70 absolute left-1 top-1 text-[0.6rem]">
|
||||
{formatTimelineTickLabel({ timeInSeconds: time, interval })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTimelineTickLabel({
|
||||
timeInSeconds,
|
||||
interval,
|
||||
}: {
|
||||
timeInSeconds: number;
|
||||
interval: number;
|
||||
}): string {
|
||||
const hours = Math.floor(timeInSeconds / 3600);
|
||||
const minutes = Math.floor((timeInSeconds % 3600) / 60);
|
||||
const secondsRemainder = timeInSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
const paddedMinutes = minutes.toString().padStart(2, "0");
|
||||
const paddedSeconds = Math.floor(secondsRemainder)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
return `${hours}:${paddedMinutes}:${paddedSeconds}`;
|
||||
}
|
||||
|
||||
if (minutes > 0) {
|
||||
const paddedSeconds = Math.floor(secondsRemainder)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
return `${minutes}:${paddedSeconds}`;
|
||||
}
|
||||
|
||||
if (interval >= 1) {
|
||||
return `${Math.floor(secondsRemainder)}s`;
|
||||
}
|
||||
|
||||
return `${secondsRemainder.toFixed(1)}s`;
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementSelection } from "@/hooks/use-element-selection";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
|
|
@ -33,11 +32,11 @@ import {
|
|||
SplitButtonSeparator,
|
||||
} from "@/components/ui/split-button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { formatTimeCode } from "@/lib/time-utils";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { EditableTimecode } from "@/components/ui/editable-timecode";
|
||||
import { ScenesView } from "../scenes-view";
|
||||
import { type TAction, invokeAction } from "@/lib/actions";
|
||||
|
||||
export function TimelineToolbar({
|
||||
zoomLevel,
|
||||
|
|
@ -46,41 +45,6 @@ export function TimelineToolbar({
|
|||
zoomLevel: number;
|
||||
setZoomLevel: ({ zoom }: { zoom: number }) => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const { selectedElements, clearSelection } = useElementSelection();
|
||||
|
||||
const handleSplitSelected = () => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.currentTime,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicateSelected = () => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element");
|
||||
return;
|
||||
}
|
||||
editor.timeline.duplicateElements({ elements: selectedElements });
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const handleSplitAndKeepLeft = () => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.currentTime,
|
||||
retainSide: "left",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSplitAndKeepRight = () => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.currentTime,
|
||||
retainSide: "right",
|
||||
});
|
||||
};
|
||||
|
||||
const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
|
||||
const newZoomLevel =
|
||||
direction === "in"
|
||||
|
|
@ -95,17 +59,9 @@ export function TimelineToolbar({
|
|||
setZoomLevel({ zoom: newZoomLevel });
|
||||
};
|
||||
|
||||
const hasNoTracks = editor.timeline.getTracks().length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-10 items-center justify-between border-b px-2 py-1">
|
||||
<ToolbarLeftSection
|
||||
hasNoTracks={hasNoTracks}
|
||||
onSplit={handleSplitSelected}
|
||||
onSplitLeft={handleSplitAndKeepLeft}
|
||||
onSplitRight={handleSplitAndKeepRight}
|
||||
onDuplicate={handleDuplicateSelected}
|
||||
/>
|
||||
<ToolbarLeftSection />
|
||||
|
||||
<SceneSelector />
|
||||
|
||||
|
|
@ -118,28 +74,26 @@ export function TimelineToolbar({
|
|||
);
|
||||
}
|
||||
|
||||
function ToolbarLeftSection({
|
||||
hasNoTracks,
|
||||
onSplit,
|
||||
onSplitLeft,
|
||||
onSplitRight,
|
||||
onDuplicate,
|
||||
}: {
|
||||
hasNoTracks: boolean;
|
||||
onSplit: () => void;
|
||||
onSplitLeft: () => void;
|
||||
onSplitRight: () => void;
|
||||
onDuplicate: () => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
function ToolbarLeftSection() {
|
||||
const { selectedElements } = useElementSelection();
|
||||
|
||||
const currentTime = editor.playback.currentTime;
|
||||
const editor = useEditor();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const isPlaying = editor.playback.isPlaying;
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
const activeProject = editor.project.getActive();
|
||||
const fps = activeProject?.fps ?? DEFAULT_FPS;
|
||||
const currentBookmarked = editor.scene.isBookmarked({ time: currentTime });
|
||||
const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime });
|
||||
|
||||
const handleAction = ({
|
||||
action,
|
||||
event,
|
||||
}: {
|
||||
action: TAction;
|
||||
event: React.MouseEvent;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
invokeAction(action);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
@ -150,7 +104,9 @@ function ToolbarLeftSection({
|
|||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => editor.playback.toggle()}
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "toggle-play", event })
|
||||
}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="size-4" />
|
||||
|
|
@ -170,7 +126,7 @@ function ToolbarLeftSection({
|
|||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => editor.playback.seek({ time: 0 })}
|
||||
onClick={(event) => handleAction({ action: "goto-start", event })}
|
||||
>
|
||||
<SkipBack className="size-4" />
|
||||
</Button>
|
||||
|
|
@ -180,13 +136,27 @@ function ToolbarLeftSection({
|
|||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
|
||||
<TimeDisplay currentTime={currentTime} duration={duration} fps={fps} />
|
||||
<TimeDisplay
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
fps={activeProject.settings.fps}
|
||||
/>
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" type="button" onClick={onSplit}>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.getCurrentTime(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Scissors className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -199,7 +169,13 @@ function ToolbarLeftSection({
|
|||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={onSplitLeft}
|
||||
onClick={() => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.getCurrentTime(),
|
||||
retainSide: "left",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ArrowLeftToLine className="size-4" />
|
||||
</Button>
|
||||
|
|
@ -213,7 +189,13 @@ function ToolbarLeftSection({
|
|||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={onSplitRight}
|
||||
onClick={() => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.getCurrentTime(),
|
||||
retainSide: "right",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ArrowRightToLine className="size-4" />
|
||||
</Button>
|
||||
|
|
@ -236,7 +218,9 @@ function ToolbarLeftSection({
|
|||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={onDuplicate}
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "duplicate-selected", event })
|
||||
}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
|
|
@ -246,18 +230,11 @@ function ToolbarLeftSection({
|
|||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toast.info("Freeze frame functionality coming soon!")
|
||||
}
|
||||
>
|
||||
<Button variant="text" size="icon" type="button" disabled>
|
||||
<Snowflake className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Freeze frame (F)</TooltipContent>
|
||||
<TooltipContent>Freeze frame (F) (Coming soon)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
|
|
@ -266,8 +243,8 @@ function ToolbarLeftSection({
|
|||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
editor.timeline.deleteElements({ elements: selectedElements })
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "delete-selected", event })
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
|
|
@ -284,7 +261,9 @@ function ToolbarLeftSection({
|
|||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => editor.scene.toggleBookmark({ time: currentTime })}
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "toggle-bookmark", event })
|
||||
}
|
||||
>
|
||||
<Bookmark
|
||||
className={`size-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
|
||||
|
|
@ -323,10 +302,10 @@ function TimeDisplay({
|
|||
/>
|
||||
<div className="text-muted-foreground px-2 font-mono text-xs">/</div>
|
||||
<div className="text-muted-foreground text-center font-mono text-xs">
|
||||
{formatTimeCode({ timeInSeconds: duration })}
|
||||
{formatTimeCode({
|
||||
timeInSeconds: duration,
|
||||
format: "HH:MM:SS:FF",
|
||||
fps,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -335,8 +314,8 @@ function TimeDisplay({
|
|||
|
||||
function SceneSelector() {
|
||||
const editor = useEditor();
|
||||
const currentScene = editor.scene.getCurrentScene();
|
||||
const scenesCount = editor.scene.getScenes().length;
|
||||
const currentScene = editor.scenes.getActiveScene();
|
||||
const scenesCount = editor.scenes.getScenes().length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -17,12 +17,12 @@ interface TimelineTrackContentProps {
|
|||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
lastMouseXRef: React.RefObject<number>;
|
||||
onElementMouseDown: (params: {
|
||||
e: React.MouseEvent;
|
||||
event: React.MouseEvent;
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
}) => void;
|
||||
onElementClick: (params: {
|
||||
e: React.MouseEvent;
|
||||
event: React.MouseEvent;
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
}) => void;
|
||||
|
|
@ -71,11 +71,11 @@ export function TimelineTrackContent({
|
|||
track={track}
|
||||
zoomLevel={zoomLevel}
|
||||
isSelected={isElementSelected}
|
||||
onElementMouseDown={(e, el) =>
|
||||
onElementMouseDown({ e, element: el, track })
|
||||
onElementMouseDown={(event, element) =>
|
||||
onElementMouseDown({ event, element, track })
|
||||
}
|
||||
onElementClick={(e, el) =>
|
||||
onElementClick({ e, element: el, track })
|
||||
onElementClick={(event, element) =>
|
||||
onElementClick({ event, element, track })
|
||||
}
|
||||
dragState={dragState}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -47,8 +47,7 @@ export function Footer() {
|
|||
<span className="text-lg font-bold">OpenCut</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-5 text-sm md:text-left">
|
||||
The open source video editor that gets the job done. Simple,
|
||||
powerful, and works on any platform.
|
||||
The privacy-first video editor that feels simple to use.
|
||||
</p>
|
||||
<div className="flex justify-start gap-3">
|
||||
<Link
|
||||
|
|
@ -81,15 +80,23 @@ export function Footer() {
|
|||
<div className="flex items-start justify-start gap-12 py-2">
|
||||
{(Object.keys(links) as Category[]).map((category) => (
|
||||
<div key={category} className="flex flex-col gap-2">
|
||||
<h3 className="text-foreground font-semibold">{capitalizeFirstLetter({ string: category })}</h3>
|
||||
<h3 className="text-foreground font-semibold">
|
||||
{capitalizeFirstLetter({ string: category })}
|
||||
</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{links[category].map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
target={link.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={link.href.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
target={
|
||||
link.href.startsWith("http") ? "_blank" : undefined
|
||||
}
|
||||
rel={
|
||||
link.href.startsWith("http")
|
||||
? "noopener noreferrer"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
|
|
@ -104,7 +111,9 @@ export function Footer() {
|
|||
{/* Bottom Section */}
|
||||
<div className="flex flex-col items-start justify-between gap-4 pt-2 md:flex-row">
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<span>© 2025 OpenCut, All Rights Reserved</span>
|
||||
<span>
|
||||
© {new Date().getFullYear()} OpenCut, All Rights Reserved
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Button } from "./ui/button";
|
|||
import { ArrowRight } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { GithubIcon, MenuIcon } from "./icons";
|
||||
import { GithubIcon, MenuIcon } from "@opencut/ui/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,24 @@ export function Handlebars({ children }: HandlebarsProps) {
|
|||
const [leftHandle, setLeftHandle] = useState(0);
|
||||
const [rightHandle, setRightHandle] = useState(0);
|
||||
|
||||
const widthRef = useRef(0);
|
||||
const leftHandlePositionRef = useRef(0);
|
||||
const rightHandlePositionRef = useRef(0);
|
||||
|
||||
const dragRef = useRef<{
|
||||
isDragging: boolean;
|
||||
side: "left" | "right" | null;
|
||||
pointerId: number | null;
|
||||
startX: number;
|
||||
initialPosition: number;
|
||||
}>({
|
||||
isDragging: false,
|
||||
side: null,
|
||||
pointerId: null,
|
||||
startX: 0,
|
||||
initialPosition: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
|
@ -31,85 +49,85 @@ export function Handlebars({ children }: HandlebarsProps) {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const leftEl = leftHandleRef.current;
|
||||
const rightEl = rightHandleRef.current;
|
||||
if (!leftEl || !rightEl) return;
|
||||
|
||||
let isDraggingLeft = false;
|
||||
let isDraggingRight = false;
|
||||
let startX = 0;
|
||||
let initialPosition = 0;
|
||||
|
||||
const handleMouseDown = (e: MouseEvent, isLeft: boolean) => {
|
||||
e.preventDefault();
|
||||
startX = e.clientX;
|
||||
|
||||
if (isLeft) {
|
||||
isDraggingLeft = true;
|
||||
initialPosition = leftHandle;
|
||||
} else {
|
||||
isDraggingRight = true;
|
||||
initialPosition = rightHandle;
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = e.clientX - startX;
|
||||
|
||||
if (isDraggingLeft) {
|
||||
const newPosition = Math.max(0, Math.min(rightHandle - 60, initialPosition + deltaX));
|
||||
setLeftHandle(newPosition);
|
||||
if (leftEl) {
|
||||
leftEl.style.transform = `translateX(${newPosition}px)`;
|
||||
}
|
||||
} else if (isDraggingRight) {
|
||||
const newPosition = Math.max(leftHandle + 60, Math.min(width, initialPosition + deltaX));
|
||||
setRightHandle(newPosition);
|
||||
if (rightEl) {
|
||||
rightEl.style.transform = `translateX(${newPosition}px)`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDraggingLeft = false;
|
||||
isDraggingRight = false;
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
const leftMouseDown = (e: MouseEvent) => handleMouseDown(e, true);
|
||||
const rightMouseDown = (e: MouseEvent) => handleMouseDown(e, false);
|
||||
|
||||
leftEl.addEventListener("mousedown", leftMouseDown);
|
||||
rightEl.addEventListener("mousedown", rightMouseDown);
|
||||
|
||||
return () => {
|
||||
leftEl.removeEventListener("mousedown", leftMouseDown);
|
||||
rightEl.removeEventListener("mousedown", rightMouseDown);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
widthRef.current = width;
|
||||
leftHandlePositionRef.current = leftHandle;
|
||||
rightHandlePositionRef.current = rightHandle;
|
||||
}, [leftHandle, rightHandle, width]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const { isDragging, side, pointerId, startX, initialPosition } =
|
||||
dragRef.current;
|
||||
|
||||
if (!isDragging) return;
|
||||
if (pointerId !== null && event.pointerId !== pointerId) return;
|
||||
if (!side) return;
|
||||
|
||||
const deltaX = event.clientX - startX;
|
||||
|
||||
if (side === "left") {
|
||||
const maxLeft = Math.max(0, rightHandlePositionRef.current - 60);
|
||||
const nextLeftHandle = Math.max(
|
||||
0,
|
||||
Math.min(maxLeft, initialPosition + deltaX),
|
||||
);
|
||||
setLeftHandle(nextLeftHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
const minRight = Math.min(
|
||||
widthRef.current,
|
||||
leftHandlePositionRef.current + 60,
|
||||
);
|
||||
const nextRightHandle = Math.max(
|
||||
minRight,
|
||||
Math.min(widthRef.current, initialPosition + deltaX),
|
||||
);
|
||||
setRightHandle(nextRightHandle);
|
||||
};
|
||||
|
||||
const handlePointerEnd = (event: PointerEvent) => {
|
||||
const { pointerId } = dragRef.current;
|
||||
if (pointerId !== null && event.pointerId !== pointerId) return;
|
||||
|
||||
dragRef.current.isDragging = false;
|
||||
dragRef.current.side = null;
|
||||
dragRef.current.pointerId = null;
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerEnd);
|
||||
window.addEventListener("pointercancel", handlePointerEnd);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerEnd);
|
||||
window.removeEventListener("pointercancel", handlePointerEnd);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const leftGradientPercent = width > 0 ? (leftHandle / (width - 10)) * 100 : 0;
|
||||
const rightGradientPercent = width > 0 ? (rightHandle / (width + 10)) * 100 : 0;
|
||||
const rightGradientPercent =
|
||||
width > 0 ? (rightHandle / (width + 10)) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="leading-16 -z-10 flex justify-center gap-4">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative -z-10 mt-0.5 -rotate-[2.76deg]"
|
||||
>
|
||||
<div className="absolute inset-0 flex h-full w-full justify-between rounded-2xl border border-yellow-500">
|
||||
<div className="leading-16 flex justify-center gap-4">
|
||||
<div ref={containerRef} className="relative mt-0.5 -rotate-[2.76deg]">
|
||||
<div className="absolute inset-0 z-10 flex h-full w-full justify-between rounded-2xl border border-yellow-500">
|
||||
<div
|
||||
ref={leftHandleRef}
|
||||
className="bg-background absolute left-0 flex h-full w-7 select-none items-center justify-center rounded-full border border-yellow-500 cursor-grab hover:scale-105 transition-transform"
|
||||
className="bg-background absolute left-0 z-20 flex h-full w-7 cursor-ew-resize touch-none select-none items-center justify-center rounded-full border border-yellow-500"
|
||||
style={{
|
||||
transform: `translateX(${leftHandle}px)`,
|
||||
translate: `${leftHandle}px 0`,
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
leftHandleRef.current?.setPointerCapture(event.pointerId);
|
||||
dragRef.current.isDragging = true;
|
||||
dragRef.current.side = "left";
|
||||
dragRef.current.pointerId = event.pointerId;
|
||||
dragRef.current.startX = event.clientX;
|
||||
dragRef.current.initialPosition = leftHandlePositionRef.current;
|
||||
}}
|
||||
>
|
||||
<div className="h-8 w-2 rounded-full bg-yellow-500" />
|
||||
|
|
@ -117,9 +135,18 @@ export function Handlebars({ children }: HandlebarsProps) {
|
|||
|
||||
<div
|
||||
ref={rightHandleRef}
|
||||
className="bg-background absolute -left-[30px] flex h-full w-7 select-none items-center justify-center rounded-full border border-yellow-500 cursor-grab hover:scale-105 transition-transform]"
|
||||
className="bg-background absolute -left-[30px] z-20 flex h-full w-7 cursor-ew-resize touch-none select-none items-center justify-center rounded-full border border-yellow-500"
|
||||
style={{
|
||||
transform: `translateX(${rightHandle}px)`,
|
||||
translate: `${rightHandle}px 0`,
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
rightHandleRef.current?.setPointerCapture(event.pointerId);
|
||||
dragRef.current.isDragging = true;
|
||||
dragRef.current.side = "right";
|
||||
dragRef.current.pointerId = event.pointerId;
|
||||
dragRef.current.startX = event.clientX;
|
||||
dragRef.current.initialPosition = rightHandlePositionRef.current;
|
||||
}}
|
||||
>
|
||||
<div className="h-8 w-2 rounded-full bg-yellow-500" />
|
||||
|
|
@ -127,7 +154,7 @@ export function Handlebars({ children }: HandlebarsProps) {
|
|||
</div>
|
||||
|
||||
<span
|
||||
className="relative inline-flex h-full w-full items-center justify-center rounded-2xl px-9 will-change-auto"
|
||||
className="relative z-0 inline-flex h-full w-full items-center justify-center rounded-2xl px-9 will-change-auto"
|
||||
style={{
|
||||
mask: `linear-gradient(90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function Hero() {
|
|||
/>
|
||||
<div className="max-w-3xl mx-auto w-full flex-1 flex flex-col justify-center">
|
||||
<div className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]">
|
||||
<h1>The Open Source</h1>
|
||||
<h1>The open source</h1>
|
||||
<Handlebars>Video Editor</Handlebars>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
useKeybindingsListener,
|
||||
useKeybindingDisabler,
|
||||
|
|
@ -10,44 +11,113 @@ import {
|
|||
import { useEditorActions } from "@/hooks/use-editor-actions";
|
||||
|
||||
interface EditorProviderProps {
|
||||
projectId: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EditorProvider({ children }: EditorProviderProps) {
|
||||
const { isInitializing, isPanelsReady, initializeApp } = useEditorStore();
|
||||
export function EditorProvider({ projectId, children }: EditorProviderProps) {
|
||||
const editor = useEditor();
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { disableKeybindings, enableKeybindings } = useKeybindingDisabler();
|
||||
const activeProject = editor.project.getActiveOrNull();
|
||||
|
||||
// Set up action handlers
|
||||
useEditorActions();
|
||||
|
||||
// Set up keybinding listener
|
||||
useKeybindingsListener();
|
||||
|
||||
// Disable keybindings when initializing
|
||||
useEffect(() => {
|
||||
if (isInitializing || !isPanelsReady) {
|
||||
if (isLoading) {
|
||||
disableKeybindings();
|
||||
} else {
|
||||
enableKeybindings();
|
||||
}
|
||||
}, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]);
|
||||
}, [isLoading, disableKeybindings, enableKeybindings]);
|
||||
|
||||
useEffect(() => {
|
||||
initializeApp();
|
||||
}, [initializeApp]);
|
||||
let cancelled = false;
|
||||
|
||||
// Show loading screen while initializing
|
||||
if (isInitializing || !isPanelsReady) {
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await editor.project.loadProject({ id: projectId });
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
|
||||
const isNotFound =
|
||||
err instanceof Error &&
|
||||
(err.message.includes("not found") ||
|
||||
err.message.includes("does not exist"));
|
||||
|
||||
if (isNotFound) {
|
||||
try {
|
||||
const newProjectId = await editor.project.createNewProject({
|
||||
name: "Untitled Project",
|
||||
});
|
||||
router.replace(`/editor/${newProjectId}`);
|
||||
} catch (createErr) {
|
||||
setError("Failed to create project");
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load project",
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadProject();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, editor, router]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-screen w-screen flex items-center justify-center bg-background">
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Loading editor...</p>
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// App is ready, render children
|
||||
return <>{children}</>;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">Loading project...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeProject) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">Exiting project...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorRuntimeBindings />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorRuntimeBindings() {
|
||||
useEditorActions();
|
||||
useKeybindingsListener();
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ export function RenameProjectDialog({
|
|||
}) {
|
||||
const [name, setName] = useState(projectName);
|
||||
|
||||
// Reset the name when dialog opens - this is better UX than syncing with every prop change
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
setName(projectName);
|
||||
|
|
@ -51,7 +50,7 @@ export function RenameProjectDialog({
|
|||
}
|
||||
}}
|
||||
placeholder="Enter a new name"
|
||||
className="mt-0 bg-background border-2 border-border"
|
||||
className="bg-background border-border mt-0 border-2"
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
|
@ -35,14 +34,17 @@ export function StorageProvider({ children }: StorageProviderProps) {
|
|||
error: null,
|
||||
});
|
||||
|
||||
const loadAllProjects = useProjectStore((state) => state.loadAllProjects);
|
||||
const editor = useEditor();
|
||||
const hasInitialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInitialized.current) return;
|
||||
hasInitialized.current = true;
|
||||
|
||||
const initializeStorage = async () => {
|
||||
setStatus((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
try {
|
||||
// Check browser support
|
||||
const hasSupport = storageService.isFullySupported();
|
||||
|
||||
if (!hasSupport) {
|
||||
|
|
@ -51,8 +53,7 @@ export function StorageProvider({ children }: StorageProviderProps) {
|
|||
);
|
||||
}
|
||||
|
||||
// Load saved projects (media will be loaded when a project is loaded)
|
||||
await loadAllProjects();
|
||||
await editor.project.loadAllProjects();
|
||||
|
||||
setStatus({
|
||||
isInitialized: true,
|
||||
|
|
@ -72,7 +73,7 @@ export function StorageProvider({ children }: StorageProviderProps) {
|
|||
};
|
||||
|
||||
initializeStorage();
|
||||
}, [loadAllProjects]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StorageContext.Provider value={status}>{children}</StorageContext.Provider>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
|
||||
interface AudioPlayerProps {
|
||||
src: string;
|
||||
|
|
@ -23,7 +23,12 @@ export function AudioPlayer({
|
|||
trackMuted = false,
|
||||
}: AudioPlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
|
||||
const editor = useEditor();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
const volume = editor.playback.getVolume();
|
||||
const muted = editor.playback.isMuted();
|
||||
const speed = editor.playback.getSpeed();
|
||||
|
||||
// Calculate if we're within this clip's timeline range
|
||||
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
|
||||
|
|
@ -42,8 +47,8 @@ export function AudioPlayer({
|
|||
trimStart,
|
||||
Math.min(
|
||||
clipDuration - trimEnd,
|
||||
timelineTime - clipStartTime + trimStart
|
||||
)
|
||||
timelineTime - clipStartTime + trimStart,
|
||||
),
|
||||
);
|
||||
audio.currentTime = audioTime;
|
||||
};
|
||||
|
|
@ -55,8 +60,8 @@ export function AudioPlayer({
|
|||
trimStart,
|
||||
Math.min(
|
||||
clipDuration - trimEnd,
|
||||
timelineTime - clipStartTime + trimStart
|
||||
)
|
||||
timelineTime - clipStartTime + trimStart,
|
||||
),
|
||||
);
|
||||
|
||||
if (Math.abs(audio.currentTime - targetTime) > 0.5) {
|
||||
|
|
@ -71,22 +76,22 @@ export function AudioPlayer({
|
|||
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
|
||||
window.addEventListener(
|
||||
"playback-update",
|
||||
handleUpdateEvent as EventListener
|
||||
handleUpdateEvent as EventListener,
|
||||
);
|
||||
window.addEventListener("playback-speed", handleSpeed as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"playback-seek",
|
||||
handleSeekEvent as EventListener
|
||||
handleSeekEvent as EventListener,
|
||||
);
|
||||
window.removeEventListener(
|
||||
"playback-update",
|
||||
handleUpdateEvent as EventListener
|
||||
handleUpdateEvent as EventListener,
|
||||
);
|
||||
window.removeEventListener(
|
||||
"playback-speed",
|
||||
handleSpeed as EventListener
|
||||
handleSpeed as EventListener,
|
||||
);
|
||||
};
|
||||
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ const buttonVariants = cva(
|
|||
variant: {
|
||||
default:
|
||||
"bg-foreground text-background shadow-sm hover:bg-foreground/90",
|
||||
foreground:
|
||||
"bg-background text-foreground shadow-sm hover:bg-background/80",
|
||||
primary:
|
||||
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||
"primary-gradient":
|
||||
|
|
@ -35,7 +37,7 @@ const buttonVariants = cva(
|
|||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
|
|
@ -54,7 +56,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import * as React from "react";
|
|||
import { DialogProps } from "@radix-ui/react-dialog";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Dialog, DialogContent } from "./dialog";
|
||||
|
||||
|
|
|
|||
|
|
@ -11,28 +11,28 @@ import { ReactNode, useState, useRef, useEffect } from "react";
|
|||
import { createPortal } from "react-dom";
|
||||
import { Plus } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { setAssetDragData } from "@/lib/asset-drag";
|
||||
import type { AssetDragData } from "@/types/assets";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { setDragData } from "@/lib/drag-data";
|
||||
import type { TimelineDragData } from "@/types/drag";
|
||||
|
||||
export interface DraggableMediaItemProps {
|
||||
export interface DraggableItemProps {
|
||||
name: string;
|
||||
preview: ReactNode;
|
||||
dragData: AssetDragData;
|
||||
onDragStart?: (e: React.DragEvent) => void;
|
||||
onAddToTimeline?: (currentTime: number) => void;
|
||||
dragData: TimelineDragData;
|
||||
onDragStart?: ({ e }: { e: React.DragEvent }) => void;
|
||||
onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void;
|
||||
aspectRatio?: number;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
showPlusOnDrag?: boolean;
|
||||
showLabel?: boolean;
|
||||
rounded?: boolean;
|
||||
shouldShowPlusOnDrag?: boolean;
|
||||
shouldShowLabel?: boolean;
|
||||
isRounded?: boolean;
|
||||
variant?: "card" | "compact";
|
||||
isDraggable?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
}
|
||||
|
||||
export function DraggableMediaItem({
|
||||
export function DraggableItem({
|
||||
name,
|
||||
preview,
|
||||
dragData,
|
||||
|
|
@ -41,23 +41,21 @@ export function DraggableMediaItem({
|
|||
aspectRatio = 16 / 9,
|
||||
className = "",
|
||||
containerClassName,
|
||||
showPlusOnDrag = true,
|
||||
showLabel = true,
|
||||
rounded = true,
|
||||
shouldShowPlusOnDrag = true,
|
||||
shouldShowLabel = true,
|
||||
isRounded = true,
|
||||
variant = "card",
|
||||
isDraggable = true,
|
||||
isHighlighted = false,
|
||||
}: DraggableMediaItemProps) {
|
||||
}: DraggableItemProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
const currentTime = isDraggable
|
||||
? usePlaybackStore((state) => state.currentTime)
|
||||
: 0;
|
||||
const editor = useEditor();
|
||||
const highlightClassName = "ring-2 ring-primary rounded-sm bg-primary/10";
|
||||
|
||||
const handleAddToTimeline = () => {
|
||||
onAddToTimeline?.(currentTime);
|
||||
onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
|
||||
};
|
||||
|
||||
const emptyImg = new window.Image();
|
||||
|
|
@ -81,13 +79,13 @@ export function DraggableMediaItem({
|
|||
const handleDragStart = (e: React.DragEvent) => {
|
||||
e.dataTransfer.setDragImage(emptyImg, 0, 0);
|
||||
|
||||
setAssetDragData({ dataTransfer: e.dataTransfer, dragData });
|
||||
setDragData({ dataTransfer: e.dataTransfer, dragData });
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
setIsDragging(true);
|
||||
|
||||
onDragStart?.(e);
|
||||
onDragStart?.({ e });
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
|
|
@ -99,7 +97,7 @@ export function DraggableMediaItem({
|
|||
{variant === "card" ? (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn("group relative", containerClassName ?? "h-28 w-28")}
|
||||
className={cn("group relative", containerClassName ?? "size-28")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -112,8 +110,8 @@ export function DraggableMediaItem({
|
|||
ratio={aspectRatio}
|
||||
className={cn(
|
||||
"bg-panel-accent relative overflow-hidden",
|
||||
rounded && "rounded-md",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0", // Webkit-specific ghost hiding
|
||||
isRounded && "rounded-md",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
|
|
@ -127,7 +125,7 @@ export function DraggableMediaItem({
|
|||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
{showLabel && (
|
||||
{shouldShowLabel && (
|
||||
<span
|
||||
className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
|
||||
aria-label={name}
|
||||
|
|
@ -158,7 +156,7 @@ export function DraggableMediaItem({
|
|||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
<div className="h-6 w-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
|
||||
<div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
|
||||
{preview}
|
||||
</div>
|
||||
<span className="w-full flex-1 truncate text-sm">{name}</span>
|
||||
|
|
@ -166,7 +164,6 @@ export function DraggableMediaItem({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom drag preview */}
|
||||
{isDraggable &&
|
||||
isDragging &&
|
||||
typeof document !== "undefined" &&
|
||||
|
|
@ -174,8 +171,8 @@ export function DraggableMediaItem({
|
|||
<div
|
||||
className="z-9999 pointer-events-none fixed"
|
||||
style={{
|
||||
left: dragPosition.x - 40, // Center the preview (half of 80px)
|
||||
top: dragPosition.y - 40, // Center the preview (half of 80px)
|
||||
left: dragPosition.x - 40,
|
||||
top: dragPosition.y - 40,
|
||||
}}
|
||||
>
|
||||
<div className="w-[80px]">
|
||||
|
|
@ -186,7 +183,7 @@ export function DraggableMediaItem({
|
|||
<div className="h-full w-full [&_img]:h-full [&_img]:w-full [&_img]:rounded-none [&_img]:object-cover">
|
||||
{preview}
|
||||
</div>
|
||||
{showPlusOnDrag && (
|
||||
{shouldShowPlusOnDrag && (
|
||||
<PlusButton
|
||||
onClick={handleAddToTimeline}
|
||||
tooltipText="Add to timeline or drag to position"
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatTimeCode, parseTimeCode, TimeCode } from "@/lib/time-utils";
|
||||
import { DEFAULT_FPS } from "@/stores/project-store";
|
||||
import { TTimeCode } from "@/types/time";
|
||||
import { formatTimeCode, parseTimeCode } from "@/lib/time-utils";
|
||||
|
||||
interface EditableTimecodeProps {
|
||||
time: number;
|
||||
duration?: number;
|
||||
format?: TimeCode;
|
||||
fps?: number;
|
||||
duration: number;
|
||||
format?: TTimeCode;
|
||||
fps: number;
|
||||
onTimeChange?: (time: number) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
|
|
@ -19,7 +19,7 @@ export function EditableTimecode({
|
|||
time,
|
||||
duration,
|
||||
format = "HH:MM:SS:FF",
|
||||
fps = DEFAULT_FPS,
|
||||
fps,
|
||||
onTimeChange,
|
||||
className,
|
||||
disabled = false,
|
||||
|
|
@ -29,8 +29,7 @@ export function EditableTimecode({
|
|||
const [hasError, setHasError] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const enterPressedRef = useRef(false);
|
||||
|
||||
const formattedTime = formatTimeCode(time, format, fps);
|
||||
const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps });
|
||||
|
||||
const startEditing = () => {
|
||||
if (disabled) return;
|
||||
|
|
@ -48,7 +47,7 @@ export function EditableTimecode({
|
|||
};
|
||||
|
||||
const applyEdit = () => {
|
||||
const parsedTime = parseTimeCode(inputValue, format, fps);
|
||||
const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
|
||||
|
||||
if (parsedTime === null) {
|
||||
setHasError(true);
|
||||
|
|
|
|||
|
|
@ -1,102 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BackgroundType } from "@/types/editor";
|
||||
|
||||
interface ImageTimelineTreatmentProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
targetAspectRatio?: number; // Default to 16:9 for video
|
||||
className?: string;
|
||||
backgroundType?: BackgroundType;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export function ImageTimelineTreatment({
|
||||
src,
|
||||
alt,
|
||||
targetAspectRatio = 16 / 9,
|
||||
className,
|
||||
backgroundType = "blur",
|
||||
backgroundColor = "#000000",
|
||||
}: ImageTimelineTreatmentProps) {
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageDimensions, setImageDimensions] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e.currentTarget;
|
||||
setImageDimensions({
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
setImageLoaded(true);
|
||||
};
|
||||
|
||||
const imageAspectRatio = imageDimensions
|
||||
? imageDimensions.width / imageDimensions.height
|
||||
: 1;
|
||||
|
||||
const needsAspectRatioTreatment = imageAspectRatio !== targetAspectRatio;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
style={{ aspectRatio: targetAspectRatio }}
|
||||
>
|
||||
{/* Background Layer */}
|
||||
{needsAspectRatioTreatment && imageLoaded && (
|
||||
<>
|
||||
{backgroundType === "blur" && (
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className="w-full h-full object-cover filter blur-xl scale-110 opacity-60"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backgroundType === "mirror" && (
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className="w-full h-full object-cover opacity-30"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backgroundType === "color" && (
|
||||
<div className="absolute inset-0" style={{ backgroundColor }} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Main Image Layer */}
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="w-full h-full object-cover"
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{!imageLoaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-muted/30">
|
||||
<div className="animate-pulse text-xs text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -53,7 +53,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||
<input
|
||||
type={inputType}
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-accent/50 px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-background px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
paddingRight,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
|
|||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center cursor-pointer justify-center whitespace-nowrap rounded-lg px-3 py-1 text-sm font-medium ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground",
|
||||
"inline-flex items-center cursor-pointer justify-center whitespace-nowrap rounded-lg px-3 py-1 text-sm font-medium ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-panel-accent data-[state=active]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -1,133 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
|
||||
interface VideoPlayerProps {
|
||||
src: string;
|
||||
poster?: string;
|
||||
className?: string;
|
||||
clipStartTime: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
clipDuration: number;
|
||||
trackMuted?: boolean;
|
||||
}
|
||||
|
||||
export function VideoPlayer({
|
||||
src,
|
||||
poster,
|
||||
className = "",
|
||||
clipStartTime,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
clipDuration,
|
||||
trackMuted = false,
|
||||
}: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
|
||||
|
||||
// Calculate if we're within this clip's timeline range
|
||||
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
|
||||
const isInClipRange =
|
||||
currentTime >= clipStartTime && currentTime < clipEndTime;
|
||||
|
||||
// Sync playback events
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !isInClipRange) return;
|
||||
|
||||
const handleSeekEvent = (e: CustomEvent) => {
|
||||
// Always update video time, even if outside clip range
|
||||
const timelineTime = e.detail.time;
|
||||
const videoTime = Math.max(
|
||||
trimStart,
|
||||
Math.min(
|
||||
clipDuration - trimEnd,
|
||||
timelineTime - clipStartTime + trimStart
|
||||
)
|
||||
);
|
||||
video.currentTime = videoTime;
|
||||
};
|
||||
|
||||
const handleUpdateEvent = (e: CustomEvent) => {
|
||||
// Always update video time, even if outside clip range
|
||||
const timelineTime = e.detail.time;
|
||||
const targetTime = Math.max(
|
||||
trimStart,
|
||||
Math.min(
|
||||
clipDuration - trimEnd,
|
||||
timelineTime - clipStartTime + trimStart
|
||||
)
|
||||
);
|
||||
|
||||
if (Math.abs(video.currentTime - targetTime) > 0.5) {
|
||||
video.currentTime = targetTime;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSpeed = (e: CustomEvent) => {
|
||||
video.playbackRate = e.detail.speed;
|
||||
};
|
||||
|
||||
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
|
||||
window.addEventListener(
|
||||
"playback-update",
|
||||
handleUpdateEvent as EventListener
|
||||
);
|
||||
window.addEventListener("playback-speed", handleSpeed as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"playback-seek",
|
||||
handleSeekEvent as EventListener
|
||||
);
|
||||
window.removeEventListener(
|
||||
"playback-update",
|
||||
handleUpdateEvent as EventListener
|
||||
);
|
||||
window.removeEventListener(
|
||||
"playback-speed",
|
||||
handleSpeed as EventListener
|
||||
);
|
||||
};
|
||||
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
|
||||
|
||||
// Sync playback state
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (isPlaying && isInClipRange) {
|
||||
video.play().catch(() => {});
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}, [isPlaying, isInClipRange]);
|
||||
|
||||
// Sync volume and speed
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.volume = volume;
|
||||
video.muted = muted || trackMuted;
|
||||
video.playbackRate = speed;
|
||||
}, [volume, speed, muted, trackMuted]);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
poster={poster}
|
||||
className={`max-w-full max-h-full object-contain ${className}`}
|
||||
playsInline
|
||||
preload="auto"
|
||||
controls={false}
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
style={{ pointerEvents: "none" }}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<(actifons: 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,13 @@
|
|||
import { Language } from "@/components/language-select";
|
||||
|
||||
export const LANGUAGES: Language[] = [
|
||||
{ code: "US", 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: "JP", name: "Japanese" },
|
||||
{ code: "CN", name: "Chinese" },
|
||||
];
|
||||
|
|
@ -1,30 +1,5 @@
|
|||
import type { TCanvasSize, TPlatformLayout } from "@/types/editor";
|
||||
import type { TPlatformLayout } from "@/types/editor";
|
||||
|
||||
export const PLATFORM_LAYOUTS: Record<TPlatformLayout, string> = {
|
||||
tiktok: "TikTok",
|
||||
};
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
import { ExportOptions } from "@/types/export";
|
||||
|
||||
export const DEFAULT_EXPORT_OPTIONS: ExportOptions = {
|
||||
format: "mp4",
|
||||
quality: "high",
|
||||
includeAudio: true,
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { 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";
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { DataBuddyIcon, MarbleIcon, VercelIcon } from "@/components/icons";
|
||||
import { DataBuddyIcon, MarbleIcon, VercelIcon } from "@opencut/ui/icons";
|
||||
|
||||
export const SITE_URL = "https://opencut.app";
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
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",
|
||||
};
|
||||
|
|
@ -17,4 +17,13 @@ export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = {
|
|||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
transform: {
|
||||
scale: 1,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import type { TrackType } from "@/types/timeline";
|
||||
import { Video, TypeIcon, Music, Sticker } from "lucide-react";
|
||||
|
||||
export const TRACK_COLORS: Record<
|
||||
TrackType,
|
||||
{ solid: string; background: string; border: string }
|
||||
> = {
|
||||
media: {
|
||||
video: {
|
||||
solid: "bg-blue-500",
|
||||
background: "",
|
||||
border: "",
|
||||
|
|
@ -19,12 +20,18 @@ export const TRACK_COLORS: Record<
|
|||
background: "bg-[#915DBE]",
|
||||
border: "",
|
||||
},
|
||||
sticker: {
|
||||
solid: "bg-amber-500",
|
||||
background: "bg-amber-500",
|
||||
border: "",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const TRACK_HEIGHTS: Record<TrackType, number> = {
|
||||
media: 60,
|
||||
video: 60,
|
||||
text: 25,
|
||||
audio: 50,
|
||||
sticker: 50,
|
||||
} as const;
|
||||
|
||||
export const TRACK_GAP = 4;
|
||||
|
|
@ -36,8 +43,15 @@ export const TIMELINE_CONSTANTS = {
|
|||
DEFAULT_ELEMENT_DURATION: 5,
|
||||
MIN_DURATION_SECONDS: 10,
|
||||
PLAYHEAD_LOOKAHEAD_SECONDS: 30, // Padding ahead of playhead
|
||||
ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4],
|
||||
ZOOM_MIN: 0.25,
|
||||
ZOOM_MAX: 4,
|
||||
ZOOM_STEP: 0.25,
|
||||
ZOOM_LEVELS: [0.1, 0.25, 0.5, 1, 1.5, 2, 3, 4, 6, 8, 10],
|
||||
ZOOM_MIN: 0.1,
|
||||
ZOOM_MAX: 10,
|
||||
ZOOM_STEP: 0.1,
|
||||
} as const;
|
||||
|
||||
export const TRACK_ICONS: Record<TrackType, React.ReactNode> = {
|
||||
video: <Video className="text-muted-foreground h-4 w-4 shrink-0" />,
|
||||
text: <TypeIcon className="text-muted-foreground h-4 w-4 shrink-0" />,
|
||||
audio: <Music className="text-muted-foreground h-4 w-4 shrink-0" />,
|
||||
sticker: <Sticker className="text-muted-foreground h-4 w-4 shrink-0" />,
|
||||
} as const;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { PlaybackManager } from "./managers/playback-manager";
|
||||
import { TimelineManager } from "./managers/timeline-manager";
|
||||
import { SceneManager } from "./managers/scene-manager";
|
||||
import { ScenesManager } from "./managers/scenes-manager";
|
||||
import { ProjectManager } from "./managers/project-manager";
|
||||
import { MediaManager } from "./managers/media-manager";
|
||||
import { RendererManager } from "./managers/renderer-manager";
|
||||
|
|
@ -8,7 +8,6 @@ import { CommandManager } from "./managers/commands";
|
|||
import { buildScene } from "@/services/renderer/scene-builder";
|
||||
import { SceneExporter } from "@/services/renderer/scene-exporter";
|
||||
import type { ExportOptions } from "@/types/export";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
|
||||
export class EditorCore {
|
||||
private static instance: EditorCore | null = null;
|
||||
|
|
@ -16,7 +15,7 @@ export class EditorCore {
|
|||
public readonly command: CommandManager;
|
||||
public readonly playback: PlaybackManager;
|
||||
public readonly timeline: TimelineManager;
|
||||
public readonly scene: SceneManager;
|
||||
public readonly scenes: ScenesManager;
|
||||
public readonly project: ProjectManager;
|
||||
public readonly media: MediaManager;
|
||||
public readonly renderer: RendererManager;
|
||||
|
|
@ -25,7 +24,7 @@ export class EditorCore {
|
|||
this.command = new CommandManager();
|
||||
this.playback = new PlaybackManager(this);
|
||||
this.timeline = new TimelineManager(this);
|
||||
this.scene = new SceneManager(this);
|
||||
this.scenes = new ScenesManager(this);
|
||||
this.project = new ProjectManager(this);
|
||||
this.media = new MediaManager(this);
|
||||
this.renderer = new RendererManager(this);
|
||||
|
|
@ -65,29 +64,31 @@ export class EditorCore {
|
|||
try {
|
||||
const sceneGraph = buildScene({
|
||||
tracks: this.timeline.getTracks(),
|
||||
mediaFiles: this.media.getMediaFiles(),
|
||||
mediaAssets: this.media.getAssets(),
|
||||
duration,
|
||||
canvasSize: project.canvasSize,
|
||||
backgroundColor: project.backgroundColor,
|
||||
canvasSize: project.settings.canvasSize,
|
||||
background: project.settings.background,
|
||||
});
|
||||
|
||||
const exporter = new SceneExporter({
|
||||
width: project.canvasSize.width,
|
||||
height: project.canvasSize.height,
|
||||
fps: project.fps ?? DEFAULT_FPS,
|
||||
width: project.settings.canvasSize.width,
|
||||
height: project.settings.canvasSize.height,
|
||||
fps: project.settings.fps,
|
||||
format,
|
||||
quality,
|
||||
includeAudio,
|
||||
});
|
||||
|
||||
let progressHandler: ((progress: number) => void) | undefined;
|
||||
if (onProgress) {
|
||||
exporter.on("progress", onProgress);
|
||||
progressHandler = (progress: number) => onProgress({ progress });
|
||||
exporter.on("progress", progressHandler);
|
||||
}
|
||||
|
||||
const buffer = await exporter.export(sceneGraph);
|
||||
|
||||
if (onProgress) {
|
||||
exporter.off("progress", onProgress);
|
||||
if (progressHandler) {
|
||||
exporter.off("progress", progressHandler);
|
||||
}
|
||||
|
||||
if (!buffer) {
|
||||
|
|
|
|||
|
|
@ -1,83 +1,81 @@
|
|||
import type { EditorCore } from "@/core";
|
||||
import type { MediaFile } from "@/types/assets";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { videoCache } from "@/lib/video-cache";
|
||||
import { hasMediaId } from "@/lib/timeline/element-utils";
|
||||
|
||||
export class MediaManager {
|
||||
public mediaFiles: MediaFile[] = [];
|
||||
public isLoading = false;
|
||||
private assets: MediaAsset[] = [];
|
||||
private isLoading = false;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
||||
async addMediaFile({
|
||||
async addMediaAsset({
|
||||
projectId,
|
||||
file,
|
||||
asset,
|
||||
}: {
|
||||
projectId: string;
|
||||
file: Omit<MediaFile, "id">;
|
||||
asset: Omit<MediaAsset, "id">;
|
||||
}): Promise<void> {
|
||||
const newItem: MediaFile = {
|
||||
...file,
|
||||
const newAsset: MediaAsset = {
|
||||
...asset,
|
||||
id: generateUUID(),
|
||||
};
|
||||
|
||||
this.mediaFiles = [...this.mediaFiles, newItem];
|
||||
this.assets = [...this.assets, newAsset];
|
||||
this.notify();
|
||||
|
||||
try {
|
||||
await storageService.saveMediaFile({ projectId, mediaItem: newItem });
|
||||
await storageService.saveMediaAsset({ projectId, mediaAsset: newAsset });
|
||||
} catch (error) {
|
||||
console.error("Failed to save media item:", error);
|
||||
this.mediaFiles = this.mediaFiles.filter(
|
||||
(media) => media.id !== newItem.id,
|
||||
);
|
||||
console.error("Failed to save media asset:", error);
|
||||
this.assets = this.assets.filter((asset) => asset.id !== newAsset.id);
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
async removeMediaFile({
|
||||
async removeMediaAsset({
|
||||
projectId,
|
||||
id,
|
||||
}: {
|
||||
projectId: string;
|
||||
id: string;
|
||||
}): Promise<void> {
|
||||
const item = this.mediaFiles.find((media) => media.id === id);
|
||||
const asset = this.assets.find((asset) => asset.id === id);
|
||||
|
||||
videoCache.clearVideo(id);
|
||||
|
||||
if (item?.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
if (asset?.url) {
|
||||
URL.revokeObjectURL(asset.url);
|
||||
if (asset.thumbnailUrl) {
|
||||
URL.revokeObjectURL(asset.thumbnailUrl);
|
||||
}
|
||||
}
|
||||
|
||||
this.mediaFiles = this.mediaFiles.filter((media) => media.id !== id);
|
||||
this.assets = this.assets.filter((asset) => asset.id !== id);
|
||||
this.notify();
|
||||
|
||||
const tracks = this.editor.timeline.getTracks();
|
||||
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
|
||||
|
||||
for (const track of tracks) {
|
||||
for (const el of track.elements) {
|
||||
if (el.type === "media" && el.mediaId === id) {
|
||||
elementsToRemove.push({ trackId: track.id, elementId: el.id });
|
||||
for (const element of track.elements) {
|
||||
if (hasMediaId(element) && element.mediaId === id) {
|
||||
elementsToRemove.push({ trackId: track.id, elementId: element.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (elementsToRemove.length > 0) {
|
||||
this.editor.timeline.setSelectedElements({ elements: elementsToRemove });
|
||||
this.editor.timeline.deleteSelected({});
|
||||
this.editor.timeline.deleteElements({ elements: elementsToRemove });
|
||||
}
|
||||
|
||||
try {
|
||||
await storageService.deleteMediaFile({ projectId, id });
|
||||
await storageService.deleteMediaAsset({ projectId, id });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete media item:", error);
|
||||
console.error("Failed to delete media asset:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,11 +84,13 @@ export class MediaManager {
|
|||
this.notify();
|
||||
|
||||
try {
|
||||
const mediaItems = await storageService.loadAllMediaFiles({ projectId });
|
||||
this.mediaFiles = mediaItems;
|
||||
const mediaAssets = await storageService.loadAllMediaAssets({
|
||||
projectId,
|
||||
});
|
||||
this.assets = mediaAssets;
|
||||
this.notify();
|
||||
} catch (error) {
|
||||
console.error("Failed to load media items:", error);
|
||||
console.error("Failed to load media assets:", error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.notify();
|
||||
|
|
@ -98,46 +98,53 @@ export class MediaManager {
|
|||
}
|
||||
|
||||
async clearProjectMedia({ projectId }: { projectId: string }): Promise<void> {
|
||||
this.mediaFiles.forEach((item) => {
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
this.assets.forEach((asset) => {
|
||||
if (asset.url) {
|
||||
URL.revokeObjectURL(asset.url);
|
||||
}
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
if (asset.thumbnailUrl) {
|
||||
URL.revokeObjectURL(asset.thumbnailUrl);
|
||||
}
|
||||
});
|
||||
|
||||
const mediaIds = this.mediaFiles.map((item) => item.id);
|
||||
this.mediaFiles = [];
|
||||
const mediaIds = this.assets.map((asset) => asset.id);
|
||||
this.assets = [];
|
||||
this.notify();
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
mediaIds.map((id) => storageService.deleteMediaFile({ projectId, id })),
|
||||
mediaIds.map((id) =>
|
||||
storageService.deleteMediaAsset({ projectId, id }),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to clear media items from storage:", error);
|
||||
console.error("Failed to clear media assets from storage:", error);
|
||||
}
|
||||
}
|
||||
|
||||
clearAllMedia(): void {
|
||||
clearAllAssets(): void {
|
||||
videoCache.clearAll();
|
||||
|
||||
this.mediaFiles.forEach((item) => {
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
this.assets.forEach((asset) => {
|
||||
if (asset.url) {
|
||||
URL.revokeObjectURL(asset.url);
|
||||
}
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
if (asset.thumbnailUrl) {
|
||||
URL.revokeObjectURL(asset.thumbnailUrl);
|
||||
}
|
||||
});
|
||||
|
||||
this.mediaFiles = [];
|
||||
this.assets = [];
|
||||
this.notify();
|
||||
}
|
||||
|
||||
getMediaFiles(): MediaFile[] {
|
||||
return this.mediaFiles;
|
||||
getAssets(): MediaAsset[] {
|
||||
return this.assets;
|
||||
}
|
||||
|
||||
setAssets({ assets }: { assets: MediaAsset[] }): void {
|
||||
this.assets = assets;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
isLoadingMedia(): boolean {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import type { EditorCore } from "@/core";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
|
||||
export class PlaybackManager {
|
||||
public isPlaying = false;
|
||||
public currentTime = 0;
|
||||
public volume = 1;
|
||||
public muted = false;
|
||||
public previousVolume = 1;
|
||||
public speed = 1.0;
|
||||
private isPlaying = false;
|
||||
private currentTime = 0;
|
||||
private volume = 1;
|
||||
private muted = false;
|
||||
private previousVolume = 1;
|
||||
private speed = 1.0;
|
||||
private listeners = new Set<() => void>();
|
||||
private playbackTimer: number | null = null;
|
||||
private lastUpdate = 0;
|
||||
|
|
@ -18,7 +17,8 @@ export class PlaybackManager {
|
|||
const duration = this.editor.timeline.getTotalDuration();
|
||||
|
||||
if (duration > 0) {
|
||||
const fps = this.editor.project.getActiveFps() ?? DEFAULT_FPS;
|
||||
const activeProject = this.editor.project.getActive();
|
||||
const fps = activeProject.settings.fps;
|
||||
const frameOffset = 1 / fps;
|
||||
const endThreshold = Math.max(0, duration - frameOffset);
|
||||
|
||||
|
|
@ -158,7 +158,8 @@ export class PlaybackManager {
|
|||
const duration = this.editor.timeline.getTotalDuration();
|
||||
|
||||
if (duration > 0 && newTime >= duration) {
|
||||
const fps = this.editor.project.getActiveFps() ?? DEFAULT_FPS;
|
||||
const activeProject = this.editor.project.getActive();
|
||||
const fps = activeProject.settings.fps;
|
||||
const frameOffset = 1 / fps;
|
||||
const stopTime = Math.max(0, duration - frameOffset);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,57 +1,81 @@
|
|||
import type { EditorCore } from "@/core";
|
||||
import type { TProject } from "@/types/project";
|
||||
import type { TCanvasSize } from "@/types/editor";
|
||||
import type {
|
||||
TProject,
|
||||
TProjectMetadata,
|
||||
TProjectSettings,
|
||||
} from "@/types/project";
|
||||
import type { TimelineElement } from "@/types/timeline";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { toast } from "sonner";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import {
|
||||
DEFAULT_FPS,
|
||||
DEFAULT_CANVAS_SIZE,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
} from "@/constants/editor-constants";
|
||||
DEFAULT_COLOR,
|
||||
} from "@/constants/project-constants";
|
||||
import { buildDefaultScene } from "@/lib/scene-utils";
|
||||
import { generateThumbnail } from "@/lib/media-processing-utils";
|
||||
import { CURRENT_VERSION, runMigrations } from "@/lib/migrations";
|
||||
|
||||
export interface MigrationState {
|
||||
isMigrating: boolean;
|
||||
fromVersion: number | null;
|
||||
toVersion: number | null;
|
||||
projectName: string | null;
|
||||
}
|
||||
|
||||
export class ProjectManager {
|
||||
public activeProject: TProject | null = null;
|
||||
public savedProjects: TProject[] = [];
|
||||
public isLoading = true;
|
||||
public isInitialized = false;
|
||||
private active: TProject | null = null;
|
||||
private savedProjects: TProjectMetadata[] = [];
|
||||
private isLoading = true;
|
||||
private isInitialized = false;
|
||||
private invalidProjectIds = new Set<string>();
|
||||
private listeners = new Set<() => void>();
|
||||
private migrationState: MigrationState = {
|
||||
isMigrating: false,
|
||||
fromVersion: null,
|
||||
toVersion: null,
|
||||
projectName: null,
|
||||
};
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
||||
async createNewProject({ name }: { name: string }): Promise<string> {
|
||||
const mainScene = buildDefaultScene({ name: "Main scene", isMain: true });
|
||||
const newProject: TProject = {
|
||||
id: generateUUID(),
|
||||
name,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {
|
||||
id: generateUUID(),
|
||||
name,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
scenes: [mainScene],
|
||||
currentSceneId: mainScene.id,
|
||||
backgroundColor: "#000000",
|
||||
backgroundType: "color",
|
||||
blurIntensity: DEFAULT_BLUR_INTENSITY,
|
||||
fps: DEFAULT_FPS,
|
||||
canvasSize: DEFAULT_CANVAS_SIZE,
|
||||
settings: {
|
||||
fps: DEFAULT_FPS,
|
||||
canvasSize: DEFAULT_CANVAS_SIZE,
|
||||
background: {
|
||||
type: "color",
|
||||
color: DEFAULT_COLOR,
|
||||
},
|
||||
},
|
||||
version: CURRENT_VERSION,
|
||||
};
|
||||
|
||||
this.activeProject = newProject;
|
||||
this.active = newProject;
|
||||
this.notify();
|
||||
|
||||
this.editor.media.clearAllMedia();
|
||||
this.editor.timeline.clearTimeline();
|
||||
this.editor.scene.initializeScenes({
|
||||
this.editor.media.clearAllAssets();
|
||||
this.editor.scenes.initializeScenes({
|
||||
scenes: newProject.scenes,
|
||||
currentSceneId: newProject.currentSceneId,
|
||||
});
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: newProject });
|
||||
await this.loadAllProjects();
|
||||
return newProject.id;
|
||||
this.updateMetadata(newProject);
|
||||
|
||||
return newProject.metadata.id;
|
||||
} catch (error) {
|
||||
toast.error("Failed to save new project");
|
||||
throw error;
|
||||
|
|
@ -64,39 +88,55 @@ export class ProjectManager {
|
|||
this.notify();
|
||||
}
|
||||
|
||||
this.editor.media.clearAllMedia();
|
||||
this.editor.timeline.clearTimeline();
|
||||
this.editor.scene.clearScenes();
|
||||
this.editor.media.clearAllAssets();
|
||||
this.editor.scenes.clearScenes();
|
||||
|
||||
try {
|
||||
const project = await storageService.loadProject({ id });
|
||||
if (!project) {
|
||||
const result = await storageService.loadProject({ id });
|
||||
if (!result) {
|
||||
throw new Error(`Project with id ${id} not found`);
|
||||
}
|
||||
|
||||
this.activeProject = project;
|
||||
let project = result.project;
|
||||
const migrationResult = runMigrations({ project });
|
||||
|
||||
if (migrationResult.migrated) {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.setMigrationState({
|
||||
isMigrating: true,
|
||||
fromVersion: migrationResult.fromVersion ?? null,
|
||||
toVersion: migrationResult.toVersion ?? null,
|
||||
projectName: project.metadata.name,
|
||||
});
|
||||
|
||||
project = migrationResult.project;
|
||||
await storageService.saveProject({ project });
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed < 300) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300 - elapsed));
|
||||
}
|
||||
|
||||
this.setMigrationState({
|
||||
isMigrating: false,
|
||||
fromVersion: null,
|
||||
toVersion: null,
|
||||
projectName: null,
|
||||
});
|
||||
}
|
||||
|
||||
this.active = project;
|
||||
this.notify();
|
||||
|
||||
let currentScene = null;
|
||||
if (project.scenes && project.scenes.length > 0) {
|
||||
this.editor.scene.initializeScenes({
|
||||
this.editor.scenes.initializeScenes({
|
||||
scenes: project.scenes,
|
||||
currentSceneId: project.currentSceneId,
|
||||
});
|
||||
|
||||
currentScene =
|
||||
project.scenes.find((s) => s.id === project.currentSceneId) ||
|
||||
project.scenes.find((s) => s.isMain) ||
|
||||
project.scenes[0];
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.editor.media.loadProjectMedia({ projectId: id }),
|
||||
this.editor.timeline.loadProjectTimeline({
|
||||
projectId: id,
|
||||
sceneId: currentScene?.id,
|
||||
}),
|
||||
]);
|
||||
await this.editor.media.loadProjectMedia({ projectId: id });
|
||||
} catch (error) {
|
||||
console.error("Failed to load project:", error);
|
||||
throw error;
|
||||
|
|
@ -107,19 +147,21 @@ export class ProjectManager {
|
|||
}
|
||||
|
||||
async saveCurrentProject(): Promise<void> {
|
||||
if (!this.activeProject) return;
|
||||
if (!this.active) return;
|
||||
|
||||
try {
|
||||
const currentScene = this.editor.scene.getCurrentScene();
|
||||
const updatedProject = {
|
||||
...this.active,
|
||||
scenes: this.editor.scenes.getScenes(),
|
||||
metadata: {
|
||||
...this.active.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
storageService.saveProject({ project: this.activeProject }),
|
||||
this.editor.timeline.saveProjectTimeline({
|
||||
projectId: this.activeProject.id,
|
||||
sceneId: currentScene?.id,
|
||||
}),
|
||||
]);
|
||||
await this.loadAllProjects();
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.active = updatedProject;
|
||||
this.updateMetadata(updatedProject);
|
||||
} catch (error) {
|
||||
console.error("Failed to save project:", error);
|
||||
}
|
||||
|
|
@ -132,8 +174,8 @@ export class ProjectManager {
|
|||
}
|
||||
|
||||
try {
|
||||
const projects = await storageService.loadAllProjects();
|
||||
this.savedProjects = projects;
|
||||
const metadata = await storageService.loadAllProjectsMetadata();
|
||||
this.savedProjects = metadata;
|
||||
this.notify();
|
||||
} catch (error) {
|
||||
console.error("Failed to load projects:", error);
|
||||
|
|
@ -148,18 +190,18 @@ export class ProjectManager {
|
|||
try {
|
||||
await Promise.all([
|
||||
storageService.deleteProjectMedia({ projectId: id }),
|
||||
storageService.deleteProjectTimeline({ projectId: id }),
|
||||
storageService.deleteProject({ id }),
|
||||
]);
|
||||
await this.loadAllProjects();
|
||||
|
||||
if (this.activeProject?.id === id) {
|
||||
this.activeProject = null;
|
||||
this.savedProjects = this.savedProjects.filter((p) => p.id !== id);
|
||||
this.notify();
|
||||
|
||||
if (this.active?.metadata.id === id) {
|
||||
this.active = null;
|
||||
this.notify();
|
||||
|
||||
this.editor.media.clearAllMedia();
|
||||
this.editor.timeline.clearTimeline();
|
||||
this.editor.scene.clearScenes();
|
||||
this.editor.media.clearAllAssets();
|
||||
this.editor.scenes.clearScenes();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete project:", error);
|
||||
|
|
@ -167,12 +209,11 @@ export class ProjectManager {
|
|||
}
|
||||
|
||||
closeProject(): void {
|
||||
this.activeProject = null;
|
||||
this.active = null;
|
||||
this.notify();
|
||||
|
||||
this.editor.media.clearAllMedia();
|
||||
this.editor.timeline.clearTimeline();
|
||||
this.editor.scene.clearScenes();
|
||||
this.editor.media.clearAllAssets();
|
||||
this.editor.scenes.clearScenes();
|
||||
}
|
||||
|
||||
async renameProject({
|
||||
|
|
@ -182,28 +223,32 @@ export class ProjectManager {
|
|||
id: string;
|
||||
name: string;
|
||||
}): Promise<void> {
|
||||
const projectToRename = this.savedProjects.find((p) => p.id === id);
|
||||
if (!projectToRename) {
|
||||
toast.error("Project not found", {
|
||||
description: "Please try again",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedProject = {
|
||||
...projectToRename,
|
||||
name,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
await this.loadAllProjects();
|
||||
const result = await storageService.loadProject({ id });
|
||||
if (!result) {
|
||||
toast.error("Project not found", {
|
||||
description: "Please try again",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeProject?.id === id) {
|
||||
this.activeProject = updatedProject;
|
||||
const updatedProject: TProject = {
|
||||
...result.project,
|
||||
metadata: {
|
||||
...result.project.metadata,
|
||||
name,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
|
||||
if (this.active?.metadata.id === id) {
|
||||
this.active = updatedProject;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
this.updateMetadata(updatedProject);
|
||||
} catch (error) {
|
||||
console.error("Failed to rename project:", error);
|
||||
toast.error("Failed to rename project", {
|
||||
|
|
@ -213,22 +258,19 @@ export class ProjectManager {
|
|||
}
|
||||
}
|
||||
|
||||
async duplicateProject({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
}): Promise<string> {
|
||||
async duplicateProject({ id }: { id: string }): Promise<string> {
|
||||
try {
|
||||
const project = await storageService.loadProject({ id: projectId });
|
||||
if (!project) {
|
||||
const result = await storageService.loadProject({ id });
|
||||
if (!result) {
|
||||
toast.error("Project not found", {
|
||||
description: "Please try again",
|
||||
});
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/);
|
||||
const baseName = numberMatch ? numberMatch[2] : project.name;
|
||||
const project = result.project;
|
||||
const numberMatch = project.metadata.name.match(/^\((\d+)\)\s+(.+)$/);
|
||||
const baseName = numberMatch ? numberMatch[2] : project.metadata.name;
|
||||
const existingNumbers: number[] = [];
|
||||
|
||||
this.savedProjects.forEach((p) => {
|
||||
|
|
@ -241,17 +283,33 @@ export class ProjectManager {
|
|||
const nextNumber =
|
||||
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
|
||||
|
||||
const newProjectId = generateUUID();
|
||||
const newProject: TProject = {
|
||||
...project,
|
||||
id: generateUUID(),
|
||||
name: `(${nextNumber}) ${baseName}`,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {
|
||||
...project.metadata,
|
||||
id: newProjectId,
|
||||
name: `(${nextNumber}) ${baseName}`,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
await storageService.saveProject({ project: newProject });
|
||||
await this.loadAllProjects();
|
||||
return newProject.id;
|
||||
|
||||
const sourceMediaAssets = await storageService.loadAllMediaAssets({
|
||||
projectId: id,
|
||||
});
|
||||
for (const asset of sourceMediaAssets) {
|
||||
await storageService.saveMediaAsset({
|
||||
projectId: newProjectId,
|
||||
mediaAsset: asset,
|
||||
});
|
||||
}
|
||||
|
||||
this.updateMetadata(newProject);
|
||||
|
||||
return newProjectId;
|
||||
} catch (error) {
|
||||
console.error("Failed to duplicate project:", error);
|
||||
toast.error("Failed to duplicate project", {
|
||||
|
|
@ -262,62 +320,86 @@ export class ProjectManager {
|
|||
}
|
||||
}
|
||||
|
||||
async updateProjectThumbnail({
|
||||
thumbnail,
|
||||
async updateSettings({
|
||||
settings,
|
||||
}: {
|
||||
thumbnail: string;
|
||||
settings: Partial<TProjectSettings>;
|
||||
}): Promise<void> {
|
||||
if (!this.activeProject) return;
|
||||
if (!this.active) return;
|
||||
|
||||
const updatedProject = {
|
||||
...this.activeProject,
|
||||
thumbnail,
|
||||
updatedAt: new Date(),
|
||||
const updatedProject: TProject = {
|
||||
...this.active,
|
||||
settings: { ...this.active.settings, ...settings },
|
||||
metadata: { ...this.active.metadata, updatedAt: new Date() },
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.activeProject = updatedProject;
|
||||
this.active = updatedProject;
|
||||
this.notify();
|
||||
await this.loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update project thumbnail:", error);
|
||||
console.error("Failed to update settings:", error);
|
||||
toast.error("Failed to update settings", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateThumbnail({ thumbnail }: { thumbnail: string }): Promise<void> {
|
||||
if (!this.active) return;
|
||||
|
||||
const updatedProject: TProject = {
|
||||
...this.active,
|
||||
metadata: { ...this.active.metadata, thumbnail, updatedAt: new Date() },
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.active = updatedProject;
|
||||
this.notify();
|
||||
this.updateMetadata(updatedProject);
|
||||
} catch (error) {
|
||||
console.error("Failed to update thumbnail:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async prepareExit(): Promise<void> {
|
||||
if (!this.activeProject) return;
|
||||
if (!this.active) return;
|
||||
|
||||
try {
|
||||
const tracks = this.editor.timeline.getTracks();
|
||||
const mediaFiles = this.editor.media.getMediaFiles();
|
||||
const mediaAssets = this.editor.media.getAssets();
|
||||
|
||||
const firstElement = tracks
|
||||
.flatMap((track) => track.elements)
|
||||
.sort((a, b) => a.startTime - b.startTime)[0];
|
||||
const allElements: TimelineElement[] = tracks.flatMap(
|
||||
(track) => track.elements as TimelineElement[],
|
||||
);
|
||||
const sortedElements = allElements.sort(
|
||||
(a, b) => a.startTime - b.startTime,
|
||||
);
|
||||
const firstElement = sortedElements[0];
|
||||
|
||||
if (
|
||||
firstElement &&
|
||||
(firstElement.type === "video" || firstElement.type === "image")
|
||||
) {
|
||||
const mediaFile = mediaFiles.find(
|
||||
(item) => item.id === firstElement.mediaId,
|
||||
const mediaAsset = mediaAssets.find(
|
||||
(asset) => asset.id === firstElement.mediaId,
|
||||
);
|
||||
|
||||
if (mediaFile) {
|
||||
if (mediaAsset) {
|
||||
let thumbnailDataUrl: string | undefined;
|
||||
|
||||
if (mediaFile.type === "video" && mediaFile.file) {
|
||||
if (mediaAsset.type === "video" && mediaAsset.file) {
|
||||
thumbnailDataUrl = await generateThumbnail({
|
||||
videoFile: mediaFile.file,
|
||||
videoFile: mediaAsset.file,
|
||||
timeInSeconds: 1,
|
||||
});
|
||||
} else if (mediaFile.type === "image" && mediaFile.url) {
|
||||
thumbnailDataUrl = mediaFile.thumbnailUrl || mediaFile.url;
|
||||
} else if (mediaAsset.type === "image" && mediaAsset.url) {
|
||||
thumbnailDataUrl = mediaAsset.thumbnailUrl || mediaAsset.url;
|
||||
}
|
||||
|
||||
if (thumbnailDataUrl && !thumbnailDataUrl.startsWith("blob:")) {
|
||||
await this.updateProjectThumbnail({ thumbnail: thumbnailDataUrl });
|
||||
await this.updateThumbnail({ thumbnail: thumbnailDataUrl });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -326,117 +408,13 @@ export class ProjectManager {
|
|||
}
|
||||
}
|
||||
|
||||
async updateProjectBackground({
|
||||
backgroundColor,
|
||||
}: {
|
||||
backgroundColor: string;
|
||||
}): Promise<void> {
|
||||
if (!this.activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...this.activeProject,
|
||||
backgroundColor,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.activeProject = updatedProject;
|
||||
this.notify();
|
||||
await this.loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update project background:", error);
|
||||
toast.error("Failed to update background", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateBackgroundType({
|
||||
type,
|
||||
options,
|
||||
}: {
|
||||
type: "color" | "blur";
|
||||
options?: { backgroundColor?: string; blurIntensity?: number };
|
||||
}): Promise<void> {
|
||||
if (!this.activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...this.activeProject,
|
||||
backgroundType: type,
|
||||
...(options?.backgroundColor && {
|
||||
backgroundColor: options.backgroundColor,
|
||||
}),
|
||||
...(options?.blurIntensity !== undefined && {
|
||||
blurIntensity: options.blurIntensity,
|
||||
}),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.activeProject = updatedProject;
|
||||
this.notify();
|
||||
await this.loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update background type:", error);
|
||||
toast.error("Failed to update background", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateProjectFps({ fps }: { fps: number }): Promise<void> {
|
||||
if (!this.activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...this.activeProject,
|
||||
fps,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.activeProject = updatedProject;
|
||||
this.notify();
|
||||
await this.loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update project FPS:", error);
|
||||
toast.error("Failed to update project FPS", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateCanvasSize({ size }: { size: TCanvasSize }): Promise<void> {
|
||||
if (!this.activeProject) return;
|
||||
|
||||
const updatedProject: TProject = {
|
||||
...this.activeProject,
|
||||
canvasSize: size,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.activeProject = updatedProject;
|
||||
this.notify();
|
||||
await this.loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update canvas size:", error);
|
||||
toast.error("Failed to update canvas size", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getFilteredAndSortedProjects({
|
||||
searchQuery,
|
||||
sortOption,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
sortOption: string;
|
||||
}): TProject[] {
|
||||
}): TProjectMetadata[] {
|
||||
const filteredProjects = this.savedProjects.filter((project) =>
|
||||
project.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
|
@ -481,15 +459,18 @@ export class ProjectManager {
|
|||
this.notify();
|
||||
}
|
||||
|
||||
getActive(): TProject | null {
|
||||
return this.activeProject;
|
||||
getActive(): TProject {
|
||||
if (!this.active) {
|
||||
throw new Error("No active project");
|
||||
}
|
||||
return this.active;
|
||||
}
|
||||
|
||||
getActiveFps(): number | undefined {
|
||||
return this.activeProject?.fps;
|
||||
getActiveOrNull(): TProject | null {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
getSavedProjects(): TProject[] {
|
||||
getSavedProjects(): TProjectMetadata[] {
|
||||
return this.savedProjects;
|
||||
}
|
||||
|
||||
|
|
@ -501,8 +482,17 @@ export class ProjectManager {
|
|||
return this.isInitialized;
|
||||
}
|
||||
|
||||
getMigrationState(): MigrationState {
|
||||
return this.migrationState;
|
||||
}
|
||||
|
||||
private setMigrationState(state: Partial<MigrationState>): void {
|
||||
this.migrationState = { ...this.migrationState, ...state };
|
||||
this.notify();
|
||||
}
|
||||
|
||||
setActiveProject({ project }: { project: TProject }): void {
|
||||
this.activeProject = project;
|
||||
this.active = project;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
|
|
@ -511,6 +501,20 @@ export class ProjectManager {
|
|||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private updateMetadata(project: TProject): void {
|
||||
const index = this.savedProjects.findIndex(
|
||||
(p) => p.id === project.metadata.id,
|
||||
);
|
||||
|
||||
if (index !== -1) {
|
||||
this.savedProjects[index] = project.metadata;
|
||||
} else {
|
||||
this.savedProjects = [project.metadata, ...this.savedProjects];
|
||||
}
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,9 @@ import type { EditorCore } from "@/core";
|
|||
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
||||
import type { ExportOptions, ExportResult } from "@/types/export";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { MediaFile } from "@/types/assets";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
import { SceneExporter } from "@/services/renderer/scene-exporter";
|
||||
import { buildScene } from "@/services/renderer/scene-builder";
|
||||
import { DEFAULT_FPS, DEFAULT_CANVAS_SIZE } from "@/constants/editor-constants";
|
||||
|
||||
export const DEFAULT_EXPORT_OPTIONS: ExportOptions = {
|
||||
format: "mp4",
|
||||
quality: "high",
|
||||
includeAudio: true,
|
||||
};
|
||||
|
||||
interface AudioElement {
|
||||
buffer: AudioBuffer;
|
||||
|
|
@ -23,7 +16,7 @@ interface AudioElement {
|
|||
}
|
||||
|
||||
export class RendererManager {
|
||||
public renderTree: RootNode | null = null;
|
||||
private renderTree: RootNode | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
|
@ -47,7 +40,7 @@ export class RendererManager {
|
|||
|
||||
try {
|
||||
const tracks = this.editor.timeline.getTracks();
|
||||
const mediaFiles = this.editor.media.getMediaFiles();
|
||||
const mediaAssets = this.editor.media.getAssets();
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
if (!activeProject) {
|
||||
|
|
@ -59,30 +52,25 @@ export class RendererManager {
|
|||
return { success: false, error: "Project is empty" };
|
||||
}
|
||||
|
||||
const exportFps = fps || activeProject.fps || DEFAULT_FPS;
|
||||
const canvasSize = activeProject.canvasSize || DEFAULT_CANVAS_SIZE;
|
||||
const exportFps = fps || activeProject.settings.fps;
|
||||
const canvasSize = activeProject.settings.canvasSize;
|
||||
|
||||
let audioBuffer: AudioBuffer | null = null;
|
||||
if (includeAudio) {
|
||||
onProgress?.(0.05);
|
||||
onProgress?.({ progress: 0.05 });
|
||||
audioBuffer = await this.createTimelineAudioBuffer({
|
||||
tracks,
|
||||
mediaFiles,
|
||||
mediaAssets,
|
||||
duration,
|
||||
});
|
||||
}
|
||||
|
||||
const scene = buildScene({
|
||||
tracks,
|
||||
mediaFiles,
|
||||
mediaAssets,
|
||||
duration,
|
||||
canvasSize,
|
||||
backgroundColor:
|
||||
activeProject.backgroundType === "blur"
|
||||
? "transparent"
|
||||
: activeProject.backgroundColor || "#000000",
|
||||
backgroundType: activeProject.backgroundType,
|
||||
blurIntensity: activeProject.blurIntensity,
|
||||
background: activeProject.settings.background,
|
||||
});
|
||||
|
||||
const exporter = new SceneExporter({
|
||||
|
|
@ -99,7 +87,7 @@ export class RendererManager {
|
|||
const adjustedProgress = includeAudio
|
||||
? 0.05 + progress * 0.95
|
||||
: progress;
|
||||
onProgress?.(adjustedProgress);
|
||||
onProgress?.({ progress: adjustedProgress });
|
||||
});
|
||||
|
||||
let cancelled = false;
|
||||
|
|
@ -142,12 +130,12 @@ export class RendererManager {
|
|||
|
||||
private async createTimelineAudioBuffer({
|
||||
tracks,
|
||||
mediaFiles,
|
||||
mediaAssets,
|
||||
duration,
|
||||
sampleRate = 44100,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
mediaFiles: MediaFile[];
|
||||
mediaAssets: MediaAsset[];
|
||||
duration: number;
|
||||
sampleRate?: number;
|
||||
}): Promise<AudioBuffer | null> {
|
||||
|
|
@ -158,8 +146,8 @@ export class RendererManager {
|
|||
const audioContext = new AudioContextClass();
|
||||
|
||||
const audioElements: AudioElement[] = [];
|
||||
const mediaMap = new Map<string, MediaFile>(
|
||||
mediaFiles.map((m) => [m.id, m]),
|
||||
const mediaMap = new Map<string, MediaAsset>(
|
||||
mediaAssets.map((m) => [m.id, m]),
|
||||
);
|
||||
|
||||
for (const track of tracks) {
|
||||
|
|
@ -170,20 +158,25 @@ export class RendererManager {
|
|||
continue;
|
||||
}
|
||||
|
||||
const mediaItem = mediaMap.get(element.mediaId);
|
||||
if (!mediaItem || mediaItem.type !== "audio") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const visibleDuration =
|
||||
element.duration - element.trimStart - element.trimEnd;
|
||||
if (visibleDuration <= 0) continue;
|
||||
if (element.duration <= 0) continue;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await mediaItem.file.arrayBuffer();
|
||||
const audioBuffer = await audioContext.decodeAudioData(
|
||||
arrayBuffer.slice(0),
|
||||
);
|
||||
let audioBuffer: AudioBuffer;
|
||||
|
||||
if (element.sourceType === "upload") {
|
||||
const mediaAsset = mediaMap.get(element.mediaId);
|
||||
if (!mediaAsset || mediaAsset.type !== "audio") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const arrayBuffer = await mediaAsset.file.arrayBuffer();
|
||||
audioBuffer = await audioContext.decodeAudioData(
|
||||
arrayBuffer.slice(0),
|
||||
);
|
||||
} else {
|
||||
// library audio - already has decoded buffer
|
||||
audioBuffer = element.buffer;
|
||||
}
|
||||
|
||||
audioElements.push({
|
||||
buffer: audioBuffer,
|
||||
|
|
@ -194,7 +187,7 @@ export class RendererManager {
|
|||
muted: element.muted || track.muted || false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`Failed to decode audio file ${mediaItem.name}:`, error);
|
||||
console.warn("Failed to decode audio:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -218,14 +211,12 @@ export class RendererManager {
|
|||
buffer,
|
||||
startTime,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
duration: elementDuration,
|
||||
} = element;
|
||||
|
||||
const sourceStartSample = Math.floor(trimStart * buffer.sampleRate);
|
||||
const sourceDuration = elementDuration - trimStart - trimEnd;
|
||||
const sourceLengthSamples = Math.floor(
|
||||
sourceDuration * buffer.sampleRate,
|
||||
elementDuration * buffer.sampleRate,
|
||||
);
|
||||
const outputStartSample = Math.floor(startTime * sampleRate);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
import type { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/types/project";
|
||||
import type { TScene } from "@/types/timeline";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getActiveScene,
|
||||
updateSceneInArray,
|
||||
getMainScene as getMainSceneUtil,
|
||||
getMainScene,
|
||||
ensureMainScene,
|
||||
buildDefaultScene,
|
||||
canDeleteScene,
|
||||
getFallbackSceneAfterDelete,
|
||||
normalizeScenes,
|
||||
findCurrentScene,
|
||||
} from "@/lib/scene-utils";
|
||||
import {
|
||||
|
|
@ -20,9 +18,9 @@ import {
|
|||
isBookmarkAtTime,
|
||||
} from "@/lib/timeline/bookmark-utils";
|
||||
|
||||
export class SceneManager {
|
||||
public currentScene: TScene | null = null;
|
||||
public scenes: TScene[] = [];
|
||||
export class ScenesManager {
|
||||
private active: TScene | null = null;
|
||||
private list: TScene[] = [];
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
|
@ -35,7 +33,7 @@ export class SceneManager {
|
|||
isMain: boolean;
|
||||
}): Promise<string> {
|
||||
const newScene = buildDefaultScene({ name, isMain });
|
||||
const updatedScenes = [...this.scenes, newScene];
|
||||
const updatedScenes = [...this.list, newScene];
|
||||
|
||||
try {
|
||||
await this.updateProjectWithScenes({ updatedScenes });
|
||||
|
|
@ -47,7 +45,7 @@ export class SceneManager {
|
|||
}
|
||||
|
||||
async deleteScene({ sceneId }: { sceneId: string }): Promise<void> {
|
||||
const sceneToDelete = this.scenes.find((s) => s.id === sceneId);
|
||||
const sceneToDelete = this.list.find((s) => s.id === sceneId);
|
||||
|
||||
if (!sceneToDelete) {
|
||||
throw new Error("Scene not found");
|
||||
|
|
@ -58,12 +56,12 @@ export class SceneManager {
|
|||
throw new Error(reason);
|
||||
}
|
||||
|
||||
const updatedScenes = this.scenes.filter((s) => s.id !== sceneId);
|
||||
const updatedScenes = this.list.filter((s) => s.id !== sceneId);
|
||||
|
||||
const newCurrentScene = getFallbackSceneAfterDelete({
|
||||
scenes: updatedScenes,
|
||||
deletedSceneId: sceneId,
|
||||
currentSceneId: this.currentScene?.id || null,
|
||||
currentSceneId: this.active?.id || null,
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -71,16 +69,6 @@ export class SceneManager {
|
|||
updatedScenes,
|
||||
updatedSceneId: newCurrentScene?.id,
|
||||
});
|
||||
|
||||
if (newCurrentScene && newCurrentScene.id !== this.currentScene?.id) {
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (activeProject) {
|
||||
await this.editor.timeline.loadProjectTimeline({
|
||||
projectId: activeProject.id,
|
||||
sceneId: newCurrentScene.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete scene:", error);
|
||||
throw error;
|
||||
|
|
@ -95,7 +83,7 @@ export class SceneManager {
|
|||
name: string;
|
||||
}): Promise<void> {
|
||||
const updatedScenes = updateSceneInArray({
|
||||
scenes: this.scenes,
|
||||
scenes: this.list,
|
||||
sceneId,
|
||||
updates: { name, updatedAt: new Date() },
|
||||
});
|
||||
|
|
@ -112,7 +100,7 @@ export class SceneManager {
|
|||
}
|
||||
|
||||
async switchToScene({ sceneId }: { sceneId: string }): Promise<void> {
|
||||
const targetScene = this.scenes.find((s) => s.id === sceneId);
|
||||
const targetScene = this.list.find((s) => s.id === sceneId);
|
||||
|
||||
if (!targetScene) {
|
||||
throw new Error("Scene not found");
|
||||
|
|
@ -120,67 +108,51 @@ export class SceneManager {
|
|||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
if (activeProject && this.currentScene) {
|
||||
await this.editor.timeline.saveProjectTimeline({
|
||||
projectId: activeProject.id,
|
||||
sceneId: this.currentScene.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (activeProject) {
|
||||
await this.editor.timeline.loadProjectTimeline({
|
||||
projectId: activeProject.id,
|
||||
sceneId,
|
||||
});
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
currentSceneId: sceneId,
|
||||
updatedAt: new Date(),
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
}
|
||||
|
||||
this.currentScene = targetScene;
|
||||
this.active = targetScene;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async toggleBookmark({ time }: { time: number }): Promise<void> {
|
||||
const activeScene = this.getActiveScene();
|
||||
if (!activeScene || !this.currentScene) return;
|
||||
if (!activeScene || !this.active) return;
|
||||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
|
||||
const frameTime = getFrameTime({
|
||||
time,
|
||||
fps: activeProject.fps,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
|
||||
const bookmarks = activeScene.timeline?.bookmarks || [];
|
||||
const updatedBookmarks = toggleBookmarkInArray({
|
||||
bookmarks,
|
||||
bookmarks: activeScene.bookmarks,
|
||||
frameTime,
|
||||
});
|
||||
|
||||
const updatedScenes = updateSceneInArray({
|
||||
scenes: this.scenes,
|
||||
scenes: this.list,
|
||||
sceneId: activeScene.id,
|
||||
updates: {
|
||||
timeline: {
|
||||
...activeScene.timeline,
|
||||
bookmarks: updatedBookmarks,
|
||||
},
|
||||
},
|
||||
updates: { bookmarks: updatedBookmarks },
|
||||
});
|
||||
|
||||
try {
|
||||
await this.updateProjectWithScenes({
|
||||
updatedScenes,
|
||||
updatedSceneId: activeScene.id,
|
||||
refreshProjectList: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update scene bookmarks:", error);
|
||||
|
|
@ -194,55 +166,47 @@ export class SceneManager {
|
|||
const activeScene = this.getActiveScene();
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
if (!activeScene || !this.currentScene || !activeProject) return false;
|
||||
if (!activeScene || !this.active || !activeProject) return false;
|
||||
|
||||
const frameTime = getFrameTime({
|
||||
time,
|
||||
fps: activeProject.fps,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
const bookmarks = activeScene.timeline?.bookmarks || [];
|
||||
|
||||
return isBookmarkAtTime({ bookmarks, frameTime });
|
||||
return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime });
|
||||
}
|
||||
|
||||
async removeBookmark({ time }: { time: number }): Promise<void> {
|
||||
const activeScene = this.getActiveScene();
|
||||
if (!activeScene || !this.currentScene) return;
|
||||
if (!activeScene || !this.active) return;
|
||||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
|
||||
const frameTime = getFrameTime({
|
||||
time,
|
||||
fps: activeProject.fps,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
const bookmarks = activeScene.timeline?.bookmarks || [];
|
||||
|
||||
const updatedBookmarks = removeBookmarkFromArray({
|
||||
bookmarks,
|
||||
bookmarks: activeScene.bookmarks,
|
||||
frameTime,
|
||||
});
|
||||
|
||||
if (updatedBookmarks.length === bookmarks.length) {
|
||||
if (updatedBookmarks.length === activeScene.bookmarks.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedScenes = updateSceneInArray({
|
||||
scenes: this.scenes,
|
||||
scenes: this.list,
|
||||
sceneId: activeScene.id,
|
||||
updates: {
|
||||
timeline: {
|
||||
...activeScene.timeline,
|
||||
bookmarks: updatedBookmarks,
|
||||
},
|
||||
},
|
||||
updates: { bookmarks: updatedBookmarks },
|
||||
});
|
||||
|
||||
try {
|
||||
await this.updateProjectWithScenes({
|
||||
updatedScenes,
|
||||
updatedSceneId: activeScene.id,
|
||||
refreshProjectList: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update scene bookmarks:", error);
|
||||
|
|
@ -254,22 +218,21 @@ export class SceneManager {
|
|||
|
||||
async loadProjectScenes({ projectId }: { projectId: string }): Promise<void> {
|
||||
try {
|
||||
const project = await storageService.loadProject({ id: projectId });
|
||||
if (project?.scenes) {
|
||||
const normalizedScenes = normalizeScenes({ scenes: project.scenes });
|
||||
const result = await storageService.loadProject({ id: projectId });
|
||||
if (result?.project.scenes) {
|
||||
const currentScene = findCurrentScene({
|
||||
scenes: normalizedScenes,
|
||||
currentSceneId: project.currentSceneId,
|
||||
scenes: result.project.scenes,
|
||||
currentSceneId: result.project.currentSceneId,
|
||||
});
|
||||
|
||||
this.scenes = normalizedScenes;
|
||||
this.currentScene = currentScene;
|
||||
this.list = result.project.scenes;
|
||||
this.active = currentScene;
|
||||
this.notify();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load project scenes:", error);
|
||||
this.scenes = [];
|
||||
this.currentScene = null;
|
||||
this.list = [];
|
||||
this.active = null;
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
|
@ -286,10 +249,10 @@ export class SceneManager {
|
|||
? ensuredScenes.find((s) => s.id === currentSceneId)
|
||||
: null;
|
||||
|
||||
const fallbackScene = getMainSceneUtil({ scenes: ensuredScenes });
|
||||
const fallbackScene = getMainScene({ scenes: ensuredScenes });
|
||||
|
||||
this.scenes = ensuredScenes;
|
||||
this.currentScene = currentScene || fallbackScene;
|
||||
this.list = ensuredScenes;
|
||||
this.active = currentScene || fallbackScene;
|
||||
this.notify();
|
||||
|
||||
if (ensuredScenes.length > scenes.length) {
|
||||
|
|
@ -299,7 +262,10 @@ export class SceneManager {
|
|||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: ensuredScenes,
|
||||
updatedAt: new Date(),
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
storageService
|
||||
|
|
@ -318,28 +284,50 @@ export class SceneManager {
|
|||
}
|
||||
|
||||
clearScenes(): void {
|
||||
this.scenes = [];
|
||||
this.currentScene = null;
|
||||
this.list = [];
|
||||
this.active = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
getMainScene(): TScene | null {
|
||||
return getMainSceneUtil({ scenes: this.scenes });
|
||||
}
|
||||
|
||||
getCurrentScene(): TScene | null {
|
||||
return this.currentScene;
|
||||
}
|
||||
|
||||
getActiveScene(): TScene | null {
|
||||
return getActiveScene({
|
||||
scenes: this.scenes,
|
||||
currentSceneId: this.currentScene?.id || "",
|
||||
});
|
||||
getActiveScene(): TScene {
|
||||
if (!this.active) {
|
||||
throw new Error("No active scene.");
|
||||
}
|
||||
return this.active;
|
||||
}
|
||||
|
||||
getScenes(): TScene[] {
|
||||
return this.scenes;
|
||||
return this.list;
|
||||
}
|
||||
|
||||
setScenes({
|
||||
scenes,
|
||||
activeSceneId,
|
||||
}: {
|
||||
scenes: TScene[];
|
||||
activeSceneId?: string;
|
||||
}): void {
|
||||
this.list = scenes;
|
||||
this.active = activeSceneId
|
||||
? (scenes.find((s) => s.id === activeSceneId) ?? null)
|
||||
: this.active;
|
||||
this.notify();
|
||||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes,
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
storageService.saveProject({ project: updatedProject }).catch((error) => {
|
||||
console.error("Failed to persist scenes:", error);
|
||||
});
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
|
|
@ -351,14 +339,45 @@ export class SceneManager {
|
|||
this.listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
updateSceneTracks({
|
||||
tracks,
|
||||
}: {
|
||||
tracks: import("@/types/timeline").TimelineTrack[];
|
||||
}): void {
|
||||
if (!this.active) return;
|
||||
|
||||
const updatedScene: TScene = {
|
||||
...this.active,
|
||||
tracks,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
this.list = this.list.map((s) =>
|
||||
s.id === this.active?.id ? updatedScene : s,
|
||||
);
|
||||
this.active = updatedScene;
|
||||
this.notify();
|
||||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: this.list,
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
}
|
||||
}
|
||||
|
||||
private async updateProjectWithScenes({
|
||||
updatedScenes,
|
||||
updatedSceneId,
|
||||
refreshProjectList = false,
|
||||
}: {
|
||||
updatedScenes: TScene[];
|
||||
updatedSceneId?: string;
|
||||
refreshProjectList?: boolean;
|
||||
}): Promise<void> {
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
|
|
@ -368,22 +387,21 @@ export class SceneManager {
|
|||
|
||||
const updatedScene = updatedSceneId
|
||||
? updatedScenes.find((s) => s.id === updatedSceneId)
|
||||
: this.currentScene;
|
||||
: this.active;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: updatedScenes,
|
||||
updatedAt: new Date(),
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
this.scenes = updatedScenes;
|
||||
this.currentScene = updatedScene || null;
|
||||
this.list = updatedScenes;
|
||||
this.active = updatedScene || null;
|
||||
this.notify();
|
||||
|
||||
if (refreshProjectList) {
|
||||
await this.editor.project.loadAllProjects();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,9 +5,9 @@ import type {
|
|||
TimelineTrack,
|
||||
TextElement,
|
||||
TimelineElement,
|
||||
ClipboardItem,
|
||||
} from "@/types/timeline";
|
||||
import { calculateTotalDuration } from "@/lib/timeline";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import {
|
||||
AddTrackCommand,
|
||||
RemoveTrackCommand,
|
||||
|
|
@ -17,7 +17,7 @@ import {
|
|||
UpdateElementDurationCommand,
|
||||
DeleteElementsCommand,
|
||||
DuplicateElementsCommand,
|
||||
ToggleElementsHiddenCommand,
|
||||
ToggleElementsVisibilityCommand,
|
||||
ToggleElementsMutedCommand,
|
||||
UpdateTextElementCommand,
|
||||
SplitElementsCommand,
|
||||
|
|
@ -27,16 +27,9 @@ import {
|
|||
} from "@/lib/commands/timeline";
|
||||
|
||||
export class TimelineManager {
|
||||
public sortedTracks: TimelineTrack[] = [];
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {
|
||||
this.initializeTracks();
|
||||
}
|
||||
|
||||
private initializeTracks(): void {
|
||||
this.sortedTracks = [];
|
||||
}
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
||||
addTrack({ type, index }: { type: TrackType; index?: number }): string {
|
||||
const command = new AddTrackCommand(type, index);
|
||||
|
|
@ -153,92 +146,50 @@ export class TimelineManager {
|
|||
elements: { trackId: string; elementId: string }[];
|
||||
splitTime: number;
|
||||
retainSide?: "both" | "left" | "right";
|
||||
}): void {
|
||||
}): string[] {
|
||||
const command = new SplitElementsCommand(elements, splitTime, retainSide);
|
||||
this.editor.command.execute({ command });
|
||||
return command.splitElementIds;
|
||||
}
|
||||
|
||||
getTotalDuration(): number {
|
||||
return calculateTotalDuration({ tracks: this.sortedTracks });
|
||||
return calculateTotalDuration({ tracks: this.getTracks() });
|
||||
}
|
||||
|
||||
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
|
||||
return this.sortedTracks.find((track) => track.id === trackId) ?? null;
|
||||
return this.getTracks().find((track) => track.id === trackId) ?? null;
|
||||
}
|
||||
|
||||
getElementsWithTracks({
|
||||
elements,
|
||||
}: {
|
||||
elements:
|
||||
| { trackId: string; elementId: string }[]
|
||||
| { trackId: string; elementId: string };
|
||||
}):
|
||||
| Array<{ track: TimelineTrack; element: TimelineElement }>
|
||||
| { track: TimelineTrack; element: TimelineElement }
|
||||
| null {
|
||||
const normalized = Array.isArray(elements) ? elements : [elements];
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}): Array<{ track: TimelineTrack; element: TimelineElement }> {
|
||||
const result: Array<{ track: TimelineTrack; element: TimelineElement }> =
|
||||
[];
|
||||
|
||||
for (const { trackId, elementId } of normalized) {
|
||||
for (const { trackId, elementId } of elements) {
|
||||
const track = this.getTrackById({ trackId });
|
||||
const element = track?.elements.find((el) => el.id === elementId);
|
||||
const element = track?.elements.find(
|
||||
(trackElement) => trackElement.id === elementId,
|
||||
);
|
||||
|
||||
if (track && element) {
|
||||
result.push({ track, element });
|
||||
}
|
||||
}
|
||||
|
||||
return Array.isArray(elements) ? result : (result[0] ?? null);
|
||||
return result;
|
||||
}
|
||||
|
||||
async loadProjectTimeline({
|
||||
projectId,
|
||||
sceneId,
|
||||
pasteAtTime({
|
||||
time,
|
||||
clipboardItems,
|
||||
}: {
|
||||
projectId: string;
|
||||
sceneId?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const tracks = await storageService.loadTimeline({
|
||||
projectId,
|
||||
sceneId,
|
||||
});
|
||||
|
||||
if (tracks) {
|
||||
this.updateTracks(tracks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load timeline:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async saveProjectTimeline({
|
||||
projectId,
|
||||
sceneId,
|
||||
}: {
|
||||
projectId: string;
|
||||
sceneId?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await storageService.saveTimeline({
|
||||
projectId,
|
||||
tracks: this.sortedTracks,
|
||||
sceneId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to save timeline:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeline(): void {
|
||||
this.updateTracks([]);
|
||||
}
|
||||
|
||||
pasteAtTime({ time }: { time: number }): void {
|
||||
const command = new PasteCommand(time);
|
||||
time: number;
|
||||
clipboardItems: ClipboardItem[];
|
||||
}): void {
|
||||
const command = new PasteCommand(time, clipboardItems);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
|
|
@ -270,6 +221,8 @@ export class TimelineManager {
|
|||
| "fontWeight"
|
||||
| "fontStyle"
|
||||
| "textDecoration"
|
||||
| "transform"
|
||||
| "opacity"
|
||||
>
|
||||
>;
|
||||
}): void {
|
||||
|
|
@ -286,12 +239,12 @@ export class TimelineManager {
|
|||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
toggleElementsHidden({
|
||||
toggleElementsVisibility({
|
||||
elements,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}): void {
|
||||
const command = new ToggleElementsHiddenCommand(elements);
|
||||
const command = new ToggleElementsVisibilityCommand(elements);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
|
|
@ -319,7 +272,7 @@ export class TimelineManager {
|
|||
}
|
||||
|
||||
getTracks(): TimelineTrack[] {
|
||||
return this.sortedTracks;
|
||||
return this.editor.scenes.getActiveScene()?.tracks ?? [];
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
|
|
@ -332,7 +285,7 @@ export class TimelineManager {
|
|||
}
|
||||
|
||||
updateTracks(newTracks: TimelineTrack[]): void {
|
||||
this.sortedTracks = newTracks;
|
||||
this.editor.scenes.updateSceneTracks({ tracks: newTracks });
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ import {
|
|||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementSelection } from "@/hooks/use-element-selection";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
import { computeDropTarget } from "@/lib/timeline/drop-utils";
|
||||
import type {
|
||||
|
|
@ -44,7 +44,7 @@ export function useElementInteraction({
|
|||
onSnapPointChange,
|
||||
}: UseElementInteractionProps) {
|
||||
const editor = useEditor();
|
||||
const tracks = editor.timeline.sortedTracks;
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const {
|
||||
isSelected,
|
||||
select,
|
||||
|
|
@ -81,13 +81,13 @@ export function useElementInteraction({
|
|||
setDragState(initialDragState);
|
||||
}, []);
|
||||
|
||||
// Mouse move: update drag time
|
||||
// mouse move: update drag time
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const handleMouseMove = ({ clientX }: MouseEvent) => {
|
||||
if (!timelineRef.current) return;
|
||||
lastMouseXRef.current = e.clientX;
|
||||
lastMouseXRef.current = clientX;
|
||||
|
||||
if (dragState.elementId && dragState.trackId) {
|
||||
const alreadySelected = isSelected({
|
||||
|
|
@ -103,16 +103,21 @@ export function useElementInteraction({
|
|||
}
|
||||
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseX = clientX - rect.left;
|
||||
const mouseTime = Math.max(
|
||||
0,
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
);
|
||||
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
||||
|
||||
const fps = editor.project.getActiveFps() ?? DEFAULT_FPS;
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
const fps = activeProject.settings.fps;
|
||||
const snappedTime = snapTimeToFrame({ time: adjustedTime, fps });
|
||||
setDragState((prev) => ({ ...prev, currentTime: snappedTime }));
|
||||
setDragState((previousDragState) => ({
|
||||
...previousDragState,
|
||||
currentTime: snappedTime,
|
||||
}));
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
|
|
@ -129,11 +134,11 @@ export function useElementInteraction({
|
|||
timelineRef,
|
||||
]);
|
||||
|
||||
// Mouse up: resolve drop
|
||||
// mouse up: resolve drop
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
const handleMouseUp = ({ clientX, clientY }: MouseEvent) => {
|
||||
if (!dragState.elementId || !dragState.trackId) return;
|
||||
|
||||
const containerRect = tracksContainerRef.current?.getBoundingClientRect();
|
||||
|
|
@ -143,9 +148,14 @@ export function useElementInteraction({
|
|||
return;
|
||||
}
|
||||
|
||||
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
||||
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
|
||||
if (!sourceTrack) {
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
return;
|
||||
}
|
||||
const movingElement = sourceTrack?.elements.find(
|
||||
(el) => el.id === dragState.elementId,
|
||||
({ id }) => id === dragState.elementId,
|
||||
);
|
||||
|
||||
if (!movingElement) {
|
||||
|
|
@ -158,8 +168,8 @@ export function useElementInteraction({
|
|||
movingElement.duration -
|
||||
movingElement.trimStart -
|
||||
movingElement.trimEnd;
|
||||
const mouseX = e.clientX - containerRect.left;
|
||||
const mouseY = e.clientY - containerRect.top;
|
||||
const mouseX = clientX - containerRect.left;
|
||||
const mouseY = clientY - containerRect.top;
|
||||
|
||||
const dropTarget = computeDropTarget({
|
||||
elementType: movingElement.type,
|
||||
|
|
@ -173,12 +183,18 @@ export function useElementInteraction({
|
|||
zoomLevel,
|
||||
});
|
||||
|
||||
const fps = editor.project.getActiveFps() ?? DEFAULT_FPS;
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) {
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
return;
|
||||
}
|
||||
const fps = activeProject.settings.fps;
|
||||
const snappedTime = snapTimeToFrame({ time: dropTarget.xPosition, fps });
|
||||
|
||||
if (dropTarget.isNewTrack) {
|
||||
const newTrackId = editor.timeline.addTrack({
|
||||
type: sourceTrack!.type,
|
||||
type: sourceTrack.type,
|
||||
index: dropTarget.trackIndex,
|
||||
});
|
||||
editor.timeline.moveElement({
|
||||
|
|
@ -221,20 +237,20 @@ export function useElementInteraction({
|
|||
|
||||
const handleElementMouseDown = useCallback(
|
||||
({
|
||||
e,
|
||||
event,
|
||||
element,
|
||||
track,
|
||||
}: {
|
||||
e: React.MouseEvent;
|
||||
event: ReactMouseEvent;
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
}) => {
|
||||
mouseDownLocationRef.current = { x: e.clientX, y: e.clientY };
|
||||
mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
|
||||
|
||||
const isRightClick = e.button === 2;
|
||||
const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
|
||||
const isRightClick = event.button === 2;
|
||||
const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
|
||||
|
||||
// right-click: select if not already selected
|
||||
// right-click
|
||||
if (isRightClick) {
|
||||
const alreadySelected = isSelected({
|
||||
trackId: track.id,
|
||||
|
|
@ -244,7 +260,7 @@ export function useElementInteraction({
|
|||
handleSelectionClick({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
isMultiKey: isMultiSelect,
|
||||
isMultiKey: false,
|
||||
});
|
||||
}
|
||||
return;
|
||||
|
|
@ -261,16 +277,16 @@ export function useElementInteraction({
|
|||
|
||||
// start drag
|
||||
const elementRect = (
|
||||
e.currentTarget as HTMLElement
|
||||
event.currentTarget as HTMLElement
|
||||
).getBoundingClientRect();
|
||||
const clickOffsetX = e.clientX - elementRect.left;
|
||||
const clickOffsetX = event.clientX - elementRect.left;
|
||||
const clickOffsetTime =
|
||||
clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
|
||||
startDrag({
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
startMouseX: e.clientX,
|
||||
startMouseX: event.clientX,
|
||||
startElementTime: element.startTime,
|
||||
clickOffsetTime,
|
||||
});
|
||||
|
|
@ -280,20 +296,20 @@ export function useElementInteraction({
|
|||
|
||||
const handleElementClick = useCallback(
|
||||
({
|
||||
e,
|
||||
event,
|
||||
element,
|
||||
track,
|
||||
}: {
|
||||
e: React.MouseEvent;
|
||||
event: ReactMouseEvent;
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
}) => {
|
||||
e.stopPropagation();
|
||||
event.stopPropagation();
|
||||
|
||||
// was it a drag or a click?
|
||||
if (mouseDownLocationRef.current) {
|
||||
const deltaX = Math.abs(e.clientX - mouseDownLocationRef.current.x);
|
||||
const deltaY = Math.abs(e.clientY - mouseDownLocationRef.current.y);
|
||||
const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
|
||||
const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
|
||||
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
|
||||
mouseDownLocationRef.current = null;
|
||||
return;
|
||||
|
|
@ -301,7 +317,7 @@ export function useElementInteraction({
|
|||
}
|
||||
|
||||
// modifier keys already handled in mousedown
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
||||
|
||||
// single click: select if not selected
|
||||
const alreadySelected = isSelected({
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
import { EditorCore } from "@/core";
|
||||
import { UpdateElementTrimCommand } from "@/lib/commands/timeline/element/update-element-trim";
|
||||
import { UpdateElementStartTimeCommand } from "@/lib/commands/timeline/element/update-element-start-time";
|
||||
import { UpdateElementDurationCommand } from "@/lib/commands/timeline/element/update-element-duration";
|
||||
import type { MediaFile } from "@/types/assets";
|
||||
|
||||
export interface ResizeState {
|
||||
elementId: string;
|
||||
|
|
@ -29,21 +27,20 @@ export function useTimelineElementResize({
|
|||
track,
|
||||
zoomLevel,
|
||||
}: UseTimelineElementResizeProps) {
|
||||
const editor = EditorCore.getInstance();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const [resizing, setResizing] = useState<ResizeState | null>(null);
|
||||
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
|
||||
const [currentTrimEnd, setCurrentTrimEnd] = useState(element.trimEnd);
|
||||
const [currentStartTime, setCurrentStartTime] = useState(element.startTime);
|
||||
const [currentDuration, setCurrentDuration] = useState(element.duration);
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
const mediaFiles = editor.media.getMediaFiles();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizing) return;
|
||||
|
||||
const handleDocumentMouseMove = (e: MouseEvent) => {
|
||||
updateTrimFromMouseMove({ clientX: e.clientX });
|
||||
const handleDocumentMouseMove = ({ clientX }: MouseEvent) => {
|
||||
updateTrimFromMouseMove({ clientX });
|
||||
};
|
||||
|
||||
const handleDocumentMouseUp = () => {
|
||||
|
|
@ -88,23 +85,10 @@ export function useTimelineElementResize({
|
|||
};
|
||||
|
||||
const canExtendElementDuration = () => {
|
||||
if (element.type === "text") {
|
||||
if (element.type === "text" || element.type === "image") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (element.type === "media") {
|
||||
const mediaFile = mediaFiles.find(
|
||||
(file: MediaFile) => file.id === element.mediaId,
|
||||
);
|
||||
if (!mediaFile) return false;
|
||||
|
||||
if (mediaFile.type === "image") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
|
|
@ -114,14 +98,17 @@ export function useTimelineElementResize({
|
|||
const deltaX = clientX - resizing.startX;
|
||||
const deltaTime = deltaX / (50 * zoomLevel);
|
||||
|
||||
const projectFps = activeProject?.fps || DEFAULT_FPS;
|
||||
const projectFps = activeProject.settings.fps;
|
||||
|
||||
if (resizing.side === "left") {
|
||||
const maxAllowed =
|
||||
resizing.initialDuration - resizing.initialTrimEnd - 0.1;
|
||||
const sourceDuration =
|
||||
resizing.initialTrimStart +
|
||||
resizing.initialDuration +
|
||||
resizing.initialTrimEnd;
|
||||
const maxAllowed = sourceDuration - resizing.initialTrimEnd - 0.1;
|
||||
const calculated = resizing.initialTrimStart + deltaTime;
|
||||
|
||||
if (calculated >= 0) {
|
||||
if (calculated >= 0 && calculated <= maxAllowed) {
|
||||
const newTrimStart = snapTimeToFrame({
|
||||
time: Math.min(maxAllowed, calculated),
|
||||
fps: projectFps,
|
||||
|
|
@ -131,10 +118,15 @@ export function useTimelineElementResize({
|
|||
time: resizing.initialStartTime + trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration - trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentTrimStart(newTrimStart);
|
||||
setCurrentStartTime(newStartTime);
|
||||
} else {
|
||||
setCurrentDuration(newDuration);
|
||||
} else if (calculated < 0) {
|
||||
if (canExtendElementDuration()) {
|
||||
const extensionAmount = Math.abs(calculated);
|
||||
const maxExtension = resizing.initialStartTime;
|
||||
|
|
@ -152,59 +144,63 @@ export function useTimelineElementResize({
|
|||
setCurrentStartTime(newStartTime);
|
||||
setCurrentDuration(newDuration);
|
||||
} else {
|
||||
const newTrimStart = 0;
|
||||
const trimDelta = newTrimStart - resizing.initialTrimStart;
|
||||
const trimDelta = 0 - resizing.initialTrimStart;
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: resizing.initialStartTime + trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration - trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentTrimStart(newTrimStart);
|
||||
setCurrentTrimStart(0);
|
||||
setCurrentStartTime(newStartTime);
|
||||
setCurrentDuration(newDuration);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const calculated = resizing.initialTrimEnd - deltaTime;
|
||||
const sourceDuration =
|
||||
resizing.initialTrimStart +
|
||||
resizing.initialDuration +
|
||||
resizing.initialTrimEnd;
|
||||
const newTrimEnd = resizing.initialTrimEnd - deltaTime;
|
||||
|
||||
if (calculated < 0) {
|
||||
if (newTrimEnd < 0) {
|
||||
if (canExtendElementDuration()) {
|
||||
const extensionNeeded = Math.abs(calculated);
|
||||
const extensionNeeded = Math.abs(newTrimEnd);
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration + extensionNeeded,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newTrimEnd = 0;
|
||||
|
||||
setCurrentDuration(newDuration);
|
||||
setCurrentTrimEnd(newTrimEnd);
|
||||
setCurrentTrimEnd(0);
|
||||
} else {
|
||||
const extensionToLimit = resizing.initialTrimEnd;
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration + extensionToLimit,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentDuration(newDuration);
|
||||
setCurrentTrimEnd(0);
|
||||
}
|
||||
} else {
|
||||
const currentEndTime =
|
||||
resizing.initialStartTime +
|
||||
resizing.initialDuration -
|
||||
resizing.initialTrimStart -
|
||||
resizing.initialTrimEnd;
|
||||
const desiredEndTime = currentEndTime + deltaTime;
|
||||
|
||||
const snappedEndTime = snapTimeToFrame({
|
||||
time: desiredEndTime,
|
||||
const maxTrimEnd = sourceDuration - resizing.initialTrimStart - 0.1;
|
||||
const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
|
||||
const finalTrimEnd = snapTimeToFrame({
|
||||
time: clampedTrimEnd,
|
||||
fps: projectFps,
|
||||
});
|
||||
const trimDelta = finalTrimEnd - resizing.initialTrimEnd;
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration - trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
const newTrimEnd = Math.max(
|
||||
0,
|
||||
resizing.initialDuration -
|
||||
resizing.initialTrimStart -
|
||||
(snappedEndTime - resizing.initialStartTime),
|
||||
);
|
||||
|
||||
const maxTrimEnd =
|
||||
resizing.initialDuration - resizing.initialTrimStart - 0.1;
|
||||
const finalTrimEnd = Math.min(maxTrimEnd, newTrimEnd);
|
||||
|
||||
setCurrentTrimEnd(finalTrimEnd);
|
||||
setCurrentDuration(newDuration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -212,8 +208,6 @@ export function useTimelineElementResize({
|
|||
const handleResizeEnd = () => {
|
||||
if (!resizing) return;
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
|
||||
const trimStartChanged = currentTrimStart !== resizing.initialTrimStart;
|
||||
const trimEndChanged = currentTrimEnd !== resizing.initialTrimEnd;
|
||||
const startTimeChanged = currentStartTime !== resizing.initialStartTime;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue