Compare commits

..
Author SHA1 Message Date
MazeandGitHub 93d1e3383c feat: major editor overhaul (assets, properties, timeline, fonts) (#709)
* feat: major editor overhaul (assets, properties, timeline, fonts)

Refactor editor core systems to standardize UI architecture and improve performance.

Assets & Properties:
- Replace monolithic property items with composable `Section` architecture.
- Add specialized sections for Transform, Blending, and Text.
- Implement `NumberField` with scrubbing and math evaluation.
- Add new ColorPicker with EyeDropper and multiple format support.
- Standardize asset panels using new `PanelView` layout.

Fonts & Stickers:
- Implement custom font atlas/sprite system for high-performance previews.
- Add virtualized FontPicker with search and favorites.
- Refactor stickers to use a provider-based architecture (icons, emoji, flags, shapes).
- Standardize sticker IDs to `provider:value` format.

Timeline & Interaction:
- Convert bookmarks to rich objects with notes, colors, and duration.
- Refactor drag-and-drop to use Command pattern (enabling proper undo/redo).
- Add Shift modifier to disable snapping during moves/resizes.
- Add new overlays for layout guides and text editing.

Renderer:
- Add support for multi-line text, custom line-height, and letter-spacing.
- Implement global composite operation (blend modes).
- Update sticker node to resolve dynamic provider IDs.

Infrastructure:
- Add storage migrations (v3->v6) for text weights, sticker IDs, and bookmarks.
- Update global styles and core UI components (Button, Input, Popover).

* add ts-nocheck directive to settings-legacy.tsx to suppress TypeScript errors

* fix: correct global composite operation assignment in TextNode to ensure proper blend mode handling

* deleted shadcn components with errors

* formatting

* fix linter issues

* migrate from next middleware to proxy

* add missing component back

* add breadcrumb back

* chore: add @radix-ui/react-primitive deps

* chore: more deps

* chore: add missing env vars to bun-ci

* next env
2026-02-23 03:24:02 +01:00
Maze Winther fca99d6126 refactor 2026-02-10 16:28:21 +01:00
Maze Winther 33a8705f0d fix selection not getting cleared after element deletion 2026-02-09 16:38:17 +01:00
Maze Winther fe10cee3eb fix: correct default text capitalization in text constants 2026-02-09 00:34:45 +01:00
Maze Winther 1d05f5ac56 fix linter 2026-02-08 22:23:12 +01:00
Maze Winther 395e415cc4 formatting 2026-02-08 22:22:23 +01:00
Maze Winther e50a817b97 add transform + few fixes
feat: transform
feat: batch command
fix: slider undo
fix: timeline zoom perf
refactor: make updateElement support multiple
2026-02-08 22:21:17 +01:00
Maze Winther 484ce47867 update review cursor command 2026-02-08 22:19:39 +01:00
Maze Winther 681f006b35 fix: fps not being displayed in some cases 2026-02-08 21:49:32 +01:00
Maze Winther 0282ef2223 scale timeline ruler labels to hours 2026-02-08 18:32:08 +01:00
Maze Winther dc0b57cd6a refactor: replace text-specific update command with generic 2026-02-08 18:01:45 +01:00
Maze Winther a218a52d2d persist sotring (projects) 2026-02-08 17:45:39 +01:00
Maze Winther 1c2cf80188 change default sorting from createdAt to updatedAt in projects store 2026-02-08 17:44:43 +01:00
Maze Winther cdd29eb1dc fix: resize-none 2026-02-08 17:06:16 +01:00
Maze Winther 34cfeacdbb fix: stop audio on timeline scrub 2026-02-08 17:02:49 +01:00
Maze Winther 6d55a34586 refactor: move isScrubbing state to playback manager 2026-02-08 17:01:46 +01:00
Maze Winther c6666fd385 feat: update text properties to use dynamic font size limits and scaling 2026-02-08 16:50:31 +01:00
Maze Winther 9b5fd6eb07 feat: ensure elements on main track starts at 0 2026-02-08 01:51:25 +01:00
Maze Winther 00cf7f6ab7 add todo 2026-02-08 01:23:13 +01:00
Maze Winther a82817a703 fix landing image 2026-02-08 01:17:29 +01:00
Maze Winther 39b6ea5613 type error 2026-02-07 23:57:19 +01:00
Maze Winther ae45ef8154 blog ui fixes 2026-02-07 23:14:31 +01:00
Maze Winther 49c4fcb229 refactor: remove AuthorList component 2026-02-07 22:37:19 +01:00
Maze Winther 464a6e83b9 fix rendering issue from v1 to current 2026-02-05 14:44:31 +01:00
Maze Winther d0b4b1a5fc fix migration dialog 2026-02-05 14:28:16 +01:00
Maze Winther 2257ca2e6f make storage versioning per-project + few other refactors 2026-02-05 12:53:56 +01:00
Maze Winther 490fc170c0 checkbox 2026-02-03 21:10:08 +01:00
Maze Winther feb8a07c16 remove comment 2026-02-03 14:00:43 +01:00
Maze Winther 7366cb27e9 fix v1 to v2 migration 2026-02-03 14:00:04 +01:00
Maze Winther 34daad9df7 comments 2026-02-03 08:48:32 +01:00
Maze Winther 9bc35363b9 remove hover state from export button 2026-02-03 08:33:42 +01:00
Maze Winther e84c0cfda6 comments + codebase index 2026-02-02 23:17:30 +01:00
Maze Winther 1dbc22b2f4 docs: update contribution guidelines and pull request template 2026-02-02 19:51:04 +01:00
Maze Winther ad794fa43b Merge branch 'timeline-zoom' 2026-02-02 19:46:51 +01:00
Maze Winther 9ca32e5e39 fix: click empty on track to move playhead 2026-02-02 19:44:27 +01:00
Maze Winther 5120e2efa6 feat: timeline zoom anchoring 2026-02-02 19:42:35 +01:00
Maze Winther 1aec9c5cf5 fix comment 2026-02-02 19:15:25 +01:00
Maze Winther e88a059222 increase zoom of buttons + refactor 2026-02-02 19:14:37 +01:00
Maze Winther c3f0d640ef feat: exponential zoom 2026-02-02 18:11:12 +01:00
274 changed files with 28804 additions and 10274 deletions
+41 -2
View File
@@ -49,7 +49,7 @@ Review every point below carefully to ensure files follow consistent code style
- [ ] Readability over brevity — use `element` not `el`, `event` not `e`
- [ ] Booleans are named `isSomething`, `hasSomething`, or `shouldSomething` — not `something`
- [ ] No title case in text/UI — use `Hello world` not `Hello World`
- [ ] No title case for multi-word text/UI — use `Hello world` not `Hello World`
## Tailwind & Styling
@@ -61,12 +61,15 @@ Review every point below carefully to ensure files follow consistent code style
<PlusIcon className="!size-6" /> {/* ✅ correct */}
<PlusIcon className="size-6" /> {/* ❌ wrong */}
<PlusIcon className="!size-4" /> {/* ❌ unnecessary, size-4 is default */}
<PlusIcon className="size-4" />{" "}
{/* ❌ completely wrong, 1) doesn't override and 2) size-4 is default */}
</Button>
```
## State Management (Zustand)
- [ ] React components never use `someStore.getState()` — use the `useSomeStore` hook instead
- [ ] High-frequency stores (timeline, playback, selections) use selectors — `useStore((s) => s.value)` not `const { value } = useStore()`
- [ ] Store/manager methods are not passed as props — sub-components access them directly
```tsx
@@ -91,6 +94,21 @@ Review every point below carefully to ensure files follow consistent code style
- [ ] Code is scannable — use variables and helper functions to make intent clear at a glance
- [ ] Complex logic is extracted into well-named variables or helpers
- [ ] No magic numbers or magic values — extract inline literals into named constants
- Applies to colors, durations, thresholds, sizes, config values, etc.
- If it's domain-specific to one file, a `const` at the top of that file is fine
- If it's generic enough, it belongs in `constants/`
- [ ] No redundant single/plural function variants — if a function can operate on multiple items, it should accept an array and handle both cases. Don't create `doThing()` + `doThings()`.
```tsx
// ❌ wrong — redundant variants
function updateElement({ element }: { element: Element }) { ... }
function updateElements({ elements }: { elements: Element[] }) { ... }
// ✅ correct — one function, accepts array
function updateElements({ elements }: { elements: Element[] }) { ... }
```
---
@@ -106,4 +124,25 @@ Review every point below carefully to ensure files follow consistent code style
---
> Every decision, every edit must be carefully considered. Everything matters.
## Review Methodology
Do NOT review by reading the file top-to-bottom and noting what jumps out. Instead:
1. Go through each checklist section **one at a time**
2. For each section, scan the **entire file** for violations of that specific rule
3. Only move to the next section after you've exhausted the current one
4. After all sections are checked, do a final pass: re-read every checklist item and confirm you didn't skip it
Before outputting the table, list each checklist section and confirm you checked it:
`Signatures ✓ | TypeScript ✓ | JSX ✓ | Organization ✓ | Comments ✓ | Naming ✓ | Tailwind ✓ | State ✓ | Quality ✓ | Keywords ✓`
---
## IMPORTANT: Review Rules
- **ONLY** flag issues that are explicitly covered by a checklist item above.
- Do **NOT** invent your own rules, suggestions, or "nice to haves" as primary issues.
- The review output must be a table of issues, each one mapping to a specific checklist item.
- If something looks off but isn't covered by the checklist, you can mention it as a brief side note at the end — but keep it clearly separate from the actual review. Always default to fixing the issues covered by the checklist above, unless the user says otherwise.
> You WILL miss things if you try to review the whole file in one pass. Iterate rule by rule.
-603
View File
@@ -1,603 +0,0 @@
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":2.2,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993064304}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993064304}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"D","location":"use-filmstrip.ts:114","message":"Requesting missing filmstrip frames","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"missingCount":111,"newCount":111,"firstNewTimestamp":0,"lastNewTimestamp":55},"timestamp":1769993064305}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":2.2,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993064309}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993064309}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H","location":"worker.ts:62","message":"Filmstrip extract request","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","fileType":"","fileSize":3787343,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993064641}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.13849909222110923,"tier":0,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":12,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993066624}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":0,"cachedCount":0},"timestamp":1769993066625}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":37.5,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993096424}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":0},"timestamp":1769993096424}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"D","location":"use-filmstrip.ts:114","message":"Requesting missing filmstrip frames","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"missingCount":221,"newCount":110,"firstNewTimestamp":0.25,"lastNewTimestamp":54.75},"timestamp":1769993096425}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H","location":"worker.ts:62","message":"Filmstrip extract request","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","fileType":"","fileSize":3787343,"timestampCount":110,"firstTimestamp":0.25,"lastTimestamp":54.75},"timestamp":1769993096425}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"K","location":"worker.ts:104","message":"Requesting sample for timestamp","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","timestamp":0.25},"timestamp":1769993096427}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"L","location":"worker.ts:132","message":"Sample received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","timestamp":0.25},"timestamp":1769993096451}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"J","location":"worker.ts:131","message":"Frame encoded to dataUrl","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","timestamp":0.25,"dataUrlLength":3111,"dataUrlPrefix":"data:image/jpeg;base64,/9j/4AAQSkZJRgABA"},"timestamp":1769993096460}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":0.25,"updatedCount":1,"totalExpected":221},"timestamp":1769993096461}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"F","location":"filmstrip.tsx:58","message":"Filmstrip rendered with single frame","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","framesCount":1,"firstTimestamp":0.25,"thumbnailWidth":107,"pixelsPerSecond":1875,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"containerWidth":103227},"timestamp":1769993096463}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":0.75,"updatedCount":2,"totalExpected":221},"timestamp":1769993096486}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"G","location":"filmstrip.tsx:86","message":"Filmstrip rendered with multiple frames","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","framesCount":2,"firstTimestamp":0.25,"lastTimestamp":0.75,"thumbnailWidth":107,"pixelsPerSecond":1875,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"containerWidth":103227},"timestamp":1769993096487}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":1.25,"updatedCount":3,"totalExpected":221},"timestamp":1769993096550}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":1.75,"updatedCount":4,"totalExpected":221},"timestamp":1769993096601}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":2.25,"updatedCount":5,"totalExpected":221},"timestamp":1769993096670}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":2.75,"updatedCount":6,"totalExpected":221},"timestamp":1769993096731}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":3.25,"updatedCount":7,"totalExpected":221},"timestamp":1769993096742}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":3.75,"updatedCount":8,"totalExpected":221},"timestamp":1769993096760}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":4.25,"updatedCount":9,"totalExpected":221},"timestamp":1769993096789}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":4.75,"updatedCount":10,"totalExpected":221},"timestamp":1769993096826}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":5.25,"updatedCount":11,"totalExpected":221},"timestamp":1769993096871}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":5.75,"updatedCount":12,"totalExpected":221},"timestamp":1769993096919}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":6.25,"updatedCount":13,"totalExpected":221},"timestamp":1769993096929}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":6.75,"updatedCount":14,"totalExpected":221},"timestamp":1769993096945}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":7.25,"updatedCount":15,"totalExpected":221},"timestamp":1769993096967}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":7.75,"updatedCount":16,"totalExpected":221},"timestamp":1769993096994}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":8.25,"updatedCount":17,"totalExpected":221},"timestamp":1769993097031}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":8.75,"updatedCount":18,"totalExpected":221},"timestamp":1769993097073}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":9.25,"updatedCount":19,"totalExpected":221},"timestamp":1769993097081}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":9.75,"updatedCount":20,"totalExpected":221},"timestamp":1769993097095}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":10.25,"updatedCount":21,"totalExpected":221},"timestamp":1769993097117}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":10.75,"updatedCount":22,"totalExpected":221},"timestamp":1769993097145}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":11.25,"updatedCount":23,"totalExpected":221},"timestamp":1769993097180}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":11.75,"updatedCount":24,"totalExpected":221},"timestamp":1769993097222}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":12.25,"updatedCount":25,"totalExpected":221},"timestamp":1769993097231}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":12.75,"updatedCount":26,"totalExpected":221},"timestamp":1769993097271}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":13.25,"updatedCount":27,"totalExpected":221},"timestamp":1769993097293}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":13.75,"updatedCount":28,"totalExpected":221},"timestamp":1769993097322}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":14.25,"updatedCount":29,"totalExpected":221},"timestamp":1769993097364}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":14.75,"updatedCount":30,"totalExpected":221},"timestamp":1769993097406}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":15.25,"updatedCount":31,"totalExpected":221},"timestamp":1769993097414}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":15.75,"updatedCount":32,"totalExpected":221},"timestamp":1769993097429}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":16.25,"updatedCount":33,"totalExpected":221},"timestamp":1769993097450}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":16.75,"updatedCount":34,"totalExpected":221},"timestamp":1769993097479}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":17.25,"updatedCount":35,"totalExpected":221},"timestamp":1769993097514}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":17.75,"updatedCount":36,"totalExpected":221},"timestamp":1769993097566}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":18.25,"updatedCount":37,"totalExpected":221},"timestamp":1769993097574}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":18.75,"updatedCount":38,"totalExpected":221},"timestamp":1769993097589}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":19.25,"updatedCount":39,"totalExpected":221},"timestamp":1769993097610}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":19.75,"updatedCount":40,"totalExpected":221},"timestamp":1769993097639}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":20.25,"updatedCount":41,"totalExpected":221},"timestamp":1769993097672}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":20.75,"updatedCount":42,"totalExpected":221},"timestamp":1769993097713}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":21.25,"updatedCount":43,"totalExpected":221},"timestamp":1769993097721}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":21.75,"updatedCount":44,"totalExpected":221},"timestamp":1769993097735}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":22.25,"updatedCount":45,"totalExpected":221},"timestamp":1769993097757}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":22.75,"updatedCount":46,"totalExpected":221},"timestamp":1769993097785}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":23.25,"updatedCount":47,"totalExpected":221},"timestamp":1769993097820}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":23.75,"updatedCount":48,"totalExpected":221},"timestamp":1769993097861}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":24.25,"updatedCount":49,"totalExpected":221},"timestamp":1769993097870}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":24.75,"updatedCount":50,"totalExpected":221},"timestamp":1769993097884}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":25.25,"updatedCount":51,"totalExpected":221},"timestamp":1769993097910}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":25.75,"updatedCount":52,"totalExpected":221},"timestamp":1769993097936}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-filmstrip.ts:146","message":"Filmstrip frame received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"timestamp":26.25,"updatedCount":53,"totalExpected":221},"timestamp":1769993097971}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":19.9,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993098068}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993098068}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":18.8,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993168875}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993168875}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.13849909222110923,"tier":0,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":12,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993168900}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":0,"cachedCount":0},"timestamp":1769993168901}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.15234900144322017,"tier":0,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":12,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993192703}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":0,"cachedCount":0},"timestamp":1769993192704}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.1675839015875422,"tier":0,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":12,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993192722}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":0,"cachedCount":0},"timestamp":1769993192722}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.18434229174629643,"tier":0,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":12,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993192738}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":0,"cachedCount":0},"timestamp":1769993192738}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.2027765209209261,"tier":0,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":12,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993192752}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":0,"cachedCount":0},"timestamp":1769993192753}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.22305417301301872,"tier":0,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":12,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993192770}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":0,"cachedCount":0},"timestamp":1769993192771}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.2453595903143206,"tier":0,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":12,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993192804}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":0,"cachedCount":0},"timestamp":1769993192804}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.2698955493457527,"tier":0,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":12,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993193123}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":0,"cachedCount":0},"timestamp":1769993193123}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.296885104280328,"tier":0,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":12,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993193131}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":0,"cachedCount":0},"timestamp":1769993193132}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.3265736147083608,"tier":0,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":12,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993193145}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":0,"cachedCount":0},"timestamp":1769993193146}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.3592309761791969,"tier":0,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":12,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993193163}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":0,"cachedCount":0},"timestamp":1769993193163}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.39515407379711665,"tier":0,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":12,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993193195}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":0,"cachedCount":0},"timestamp":1769993193195}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.43466948117682835,"tier":0,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":12,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993193484}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":0,"cachedCount":0},"timestamp":1769993193485}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.4781364292945112,"tier":0,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":12,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993193497}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":0,"cachedCount":0},"timestamp":1769993193497}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.5259500722239624,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993193513}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993193513}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.5785450794463587,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993193529}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993193530}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.6363995873909946,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993193921}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993193922}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.7000395461300941,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993193941}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993193942}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.7700435007431036,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993193964}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993193964}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.847047850817414,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993193988}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993193989}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.0249278994890711,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993194010}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993194011}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.1274206894379784,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993194030}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993194030}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.2401627583817763,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993194248}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993194249}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.364179034219954,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993194266}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993194266}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.5005969376419495,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993194282}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993194282}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.6506566314061446,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993194300}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993194300}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.8157222945467593,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993194316}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993194316}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.9972945240014353,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993194564}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993194565}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":2.197023976401579,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993194586}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993194586}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":2.416726374041737,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993194602}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993194603}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":2.6583990114459106,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993194618}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993194619}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":2.416726374041737,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993202785}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993202785}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":2.197023976401579,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993202816}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993202817}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.8157222945467593,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993202841}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993202841}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.6506566314061448,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993203091}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993203091}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.50059693764195,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993203103}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993203103}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.3641790342199545,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993203113}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993203114}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.2401627583817767,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993203130}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993203130}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.1274206894379788,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993203148}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993203149}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.0249278994890716,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993203365}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993203365}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.9317526358991559,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993203379}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993203379}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.8470478508174144,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993203396}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993203396}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.770043500743104,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993203414}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993203414}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.7000395461300946,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993203662}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993203662}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.636399587390995,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993203678}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993203678}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.578545079446359,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993203695}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993203696}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.5259500722239627,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993203728}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993203728}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.578545079446359,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993204155}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993204155}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.636399587390995,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993204173}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993204173}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.7000395461300946,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993204191}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993204192}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.7700435007431041,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993204212}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993204212}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.7000395461300947,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993205253}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993205253}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.6363995873909951,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993205270}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993205271}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.5785450794463591,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993205290}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993205290}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.5259500722239628,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993205309}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993205309}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.47813642929451167,"tier":0,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":12,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993205329}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":0,"cachedCount":0},"timestamp":1769993205329}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.5259500722239628,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993205721}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993205722}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.5785450794463591,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993205732}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993205732}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.6363995873909951,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993205745}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993205746}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.7000395461300947,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993205762}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993205762}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.7700435007431042,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993205780}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993205780}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.8470478508174147,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993205797}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993205797}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.9317526358991562,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993205846}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993205846}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.024927899489072,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993206065}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993206066}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.1274206894379792,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993206082}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993206082}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.2401627583817774,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993206099}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993206099}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.3641790342199551,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993206115}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993206116}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.5005969376419508,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993206131}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993206131}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.650656631406146,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993206375}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993206375}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.8157222945467606,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993206398}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993206398}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.9972945240014368,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993206420}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993206421}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":2.1970239764015806,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993206451}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993206451}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":2.416726374041739,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993206906}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993206906}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":2.6583990114459133,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993206933}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993206934}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":2.924238912590505,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993206951}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993206951}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":3.2166628038495557,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993207050}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993207050}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":3.5383290842345114,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993207547}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993207547}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":3.8921619926579627,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993207584}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993207585}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":4.28137819192376,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993207656}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993207656}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":4.709516011116136,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993207674}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993207674}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":5.18046761222775,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993207720}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993207720}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":4.709516011116136,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993208624}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993208624}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":3.892161992657963,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993208650}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993208650}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":3.538329084234512,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993208686}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993208686}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":3.8921619926579636,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993209088}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993209088}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":4.281378191923761,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993209111}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993209111}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":4.709516011116137,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993209130}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993209131}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":5.180467612227751,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993209150}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993209150}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":5.698514373450527,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993209173}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993209173}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":6.26836581079558,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993209194}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993209194}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":6.895202391875139,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993209253}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993209254}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":7.584722631062653,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993209506}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993209507}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":8.343194894168919,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993209531}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993209532}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":9.177514383585812,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993209585}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993209585}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":11.104792404138836,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993209652}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993209652}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":12.21527164455272,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993209826}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993209826}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":14.780478689908794,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993209872}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993209872}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":16.258526558899675,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993210108}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993210109}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":17.884379214789643,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993210154}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993210154}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":19.67281713626861,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993210196}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993210197}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":21.640098849895473,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993210407}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993210408}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":26.184519608373527,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993210454}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993210455}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":31.683268726131974,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993210483}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993210484}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":34.85159559874518,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993210738}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993210739}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":42.170430674481665,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993210799}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993210800}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":46.38747374192984,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993210979}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993210980}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":51.02622111612283,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993211007}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993211008}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":61.74172755050863,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993211035}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993211036}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":67.91590030555949,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993211282}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993211282}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":74.70749033611544,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993211312}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993211312}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":90.3960633066997,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993211340}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993211340}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":99.43566963736968,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993211581}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993211581}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":100,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993211612}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993211612}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":90.9090909090909,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993212736}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993212736}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":75.13148009015777,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993212765}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993212765}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":68.30134553650706,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993212793}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993212794}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":56.447393005377734,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993212824}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993212824}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":51.31581182307067,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993213111}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993213111}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":46.650738020973336,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993213138}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993213139}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":38.55432894295317,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993213167}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993213167}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":35.04938994813924,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993213218}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993213218}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":31.863081771035674,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993213249}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993213249}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":28.966437973668793,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993213518}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993213518}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":23.939204936916358,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993213576}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993213577}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":21.76291357901487,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993213860}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993213860}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":19.78446689001352,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993213891}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993213891}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":17.985878990921382,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993213918}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993213918}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":16.3507990826558,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993213945}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993213946}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":14.864362802414364,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993213974}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993213974}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":13.513057093103967,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993214246}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993214246}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":11.167815779424766,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993214273}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993214273}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":10.152559799477059,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993214308}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993214308}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":9.229599817706417,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993214325}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993214326}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993214629}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":8.390545288824015,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993214629}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":6.934334949441334,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993214674}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993214674}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":6.303940863128485,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993214699}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993214699}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":5.730855330116804,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993214714}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993214714}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":5.209868481924367,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993214983}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993214983}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":4.736244074476697,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993215006}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993215006}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":4.305676431342452,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993215021}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993215022}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":3.9142513012204105,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993215049}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993215050}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":3.5584102738367367,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993215085}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993215085}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":3.2349184307606698,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993215113}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993215113}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":2.940834937055154,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993215380}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993215380}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":2.673486306413776,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993215398}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993215398}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":2.4304420967397964,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993215430}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993215430}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":2.2094928152179967,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993215451}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993215452}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":2.0086298320163607,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993215480}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993215480}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.8260271200148732,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993215789}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993215789}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.6600246545589756,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993215811}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993215811}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.5091133223263415,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993215833}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993215833}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.371921202114856,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993215856}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993215857}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.2472010928316872,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993215877}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993215877}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.1338191753015339,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993216180}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993216181}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.0307447048195761,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993216198}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993216198}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.9370406407450692,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993216216}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993216217}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.8518551279500629,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993216231}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993216231}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":12.6,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993217645}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993217645}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":13.6,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993217688}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993217688}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":14.7,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993218766}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993218766}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":15.7,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993218823}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993218823}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":16.7,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993218888}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993218888}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":17.8,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993219008}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993219008}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":18.8,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993219046}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993219047}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":19.9,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993219086}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993219086}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":20.9,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993219193}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993219193}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":21.9,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993219260}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993219260}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":23,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993219380}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993219381}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":24,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993219461}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993219461}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":25.1,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993219489}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993219489}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":26.1,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993219558}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993219558}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":29.2,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993219597}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993219597}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":31.3,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993219629}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993219630}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":32.3,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993219657}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993219657}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":33.4,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993219685}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993219685}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":34.4,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993219712}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993219712}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":35.5,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993219827}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993219827}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":37.5,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993219948}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993219948}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":40.7,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993219977}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993219977}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":42.7,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220005}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220006}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":43.8,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220037}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220038}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":45.9,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220066}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220067}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":52.1,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220094}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220094}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":56.3,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220122}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220122}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":63.6,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220152}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220153}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":65.6,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220180}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220180}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":67.7,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220207}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220207}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":68.8,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220235}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220235}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":70.8,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220279}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220279}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":72.9,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220392}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220393}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":74,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220427}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220428}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":76,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220454}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220454}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":77.1,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220493}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220494}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":78.1,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220559}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220559}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":79.2,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220618}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220618}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":81.2,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220652}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220653}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":82.3,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220680}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220681}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":83.3,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220708}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220708}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":85.4,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220737}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220737}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":86.4,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220766}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220766}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":87.5,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220793}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220794}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":89.6,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220876}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220877}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":90.6,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220904}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220904}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":91.6,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220930}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220931}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":92.7,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220957}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220958}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":93.7,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993220994}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993220994}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":95.8,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993221021}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993221022}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":96.8,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993221049}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993221049}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":98.9,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993221077}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993221077}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":100,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993221107}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993221107}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":98.9,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993221749}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993221750}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":96.8,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993221788}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993221788}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":95.8,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993221814}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993221815}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":94.8,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993221845}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993221846}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":93.7,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993221893}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993221893}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":92.7,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993221919}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993221920}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":91.6,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222009}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222010}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":90.6,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222060}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222062}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":88.5,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222145}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222145}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":87.5,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222183}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222184}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":85.4,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222222}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222222}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":84.4,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222250}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222250}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":83.3,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222277}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222277}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":82.3,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222304}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222305}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":80.2,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222333}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222333}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":77.1,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222362}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222363}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":74,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222390}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222390}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":71.9,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222419}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222419}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":70.8,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222446}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222446}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":68.8,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222475}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222475}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":65.6,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222503}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222503}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":60.4,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222531}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222532}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":57.3,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222559}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222559}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":54.2,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222590}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222590}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":51.1,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222617}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222618}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":45.9,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222645}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222645}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":41.7,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222673}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222674}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":39.6,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222703}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222704}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":37.5,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222772}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222772}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":34.4,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222832}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222833}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":28.2,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222866}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222866}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":27.1,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222894}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222894}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":26.1,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222921}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222922}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":25.1,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222949}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993222950}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":23,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993222999}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993223000}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":18.8,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993223028}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993223028}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":16.7,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993223057}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993223057}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":14.7,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993223085}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993223085}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":13.6,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993223130}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993223130}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":12.6,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993223159}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993223159}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":11.5,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993223188}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993223188}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":10.5,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993223223}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993223223}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":9.5,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993223241}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993223241}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":8.4,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993223285}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993223285}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":7.4,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993223303}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993223304}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":6.3,"tier":3,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":221,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993223339}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":3,"cachedCount":53},"timestamp":1769993223340}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":4.3,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993223387}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993223388}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":3.2,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993223417}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993223418}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":2.2,"tier":2,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":111,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993223484}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":2,"cachedCount":0},"timestamp":1769993223484}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":1.1,"tier":1,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":56,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993223499}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":1,"cachedCount":0},"timestamp":1769993223499}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"A","location":"use-filmstrip.ts:58","message":"Computed filmstrip timestamps","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","zoomLevel":0.13849909222110923,"tier":0,"duration":55.05451247165533,"visibleStartTime":0,"visibleEndTime":55.05451247165533,"timestampCount":12,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993223560}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"C","location":"use-filmstrip.ts:82","message":"Cached frames loaded","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","tier":0,"cachedCount":0},"timestamp":1769993223561}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H","location":"worker.ts:62","message":"Filmstrip extract request","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","fileType":"","fileSize":3787343,"timestampCount":12,"firstTimestamp":0,"lastTimestamp":55},"timestamp":1769993244027}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"K","location":"worker.ts:104","message":"Requesting sample for timestamp","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","timestamp":0},"timestamp":1769993244030}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"L","location":"worker.ts:132","message":"Sample received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","timestamp":0},"timestamp":1769993244050}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"J","location":"worker.ts:131","message":"Frame encoded to dataUrl","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","timestamp":0,"dataUrlLength":3067,"dataUrlPrefix":"data:image/jpeg;base64,/9j/4AAQSkZJRgABA"},"timestamp":1769993244064}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"K","location":"worker.ts:104","message":"Requesting sample for timestamp","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","timestamp":55},"timestamp":1769993244403}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"L","location":"worker.ts:132","message":"Sample received","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","timestamp":55},"timestamp":1769993244419}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"J","location":"worker.ts:131","message":"Frame encoded to dataUrl","data":{"mediaId":"32045f82-11be-4928-9542-ec0844f6306a","timestamp":55,"dataUrlLength":1263,"dataUrlPrefix":"data:image/jpeg;base64,/9j/4AAQSkZJRgABA"},"timestamp":1769993244429}
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
{
"plugins": {
"figma": {
"enabled": true
}
}
}
+10
View File
@@ -1,5 +1,15 @@
# Contributing to OpenCut
⚠️ We are currently NOT accepting feature PRs while we build out the core editor.
If you want to contribute:
1. Open an issue first to discuss
2. Wait for maintainer approval
3. Only then start coding
Critical bug fixes may be accepted on a case-by-case basis.
Thank you for your interest in contributing to OpenCut! This document provides guidelines and instructions for contributing.
## Getting Started
+7 -46
View File
@@ -1,50 +1,11 @@
## Description
⚠️ READ BEFORE SUBMITTING ⚠️
Please include a summary of the changes and the related issue. Please also include relevant motivation and context.
We are not currently accepting PRs except for critical bugs.
Fixes # (issue)
If this is a bug fix:
- [ ] I've opened an issue first
- [ ] This was approved by a maintainer
## Type of change
If this is a feature:
Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
- [ ] Performance improvement
- [ ] Code refactoring
- [ ] Tests
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration
- [ ] Test A
- [ ] Test B
**Test Configuration**:
- Node version:
- Browser (if applicable):
- Operating System:
## Screenshots (if applicable)
Add screenshots to help explain your changes.
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have added screenshots if ui has been changed
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream modules
## Additional context
Add any other context about the pull request here.
This PR will be closed. Please open an issue to discuss first.
+10 -1
View File
@@ -27,7 +27,16 @@ jobs:
NEXT_PUBLIC_SITE_URL: "http://localhost:3000"
UPSTASH_REDIS_REST_URL: "https://your-upstash-redis-url"
UPSTASH_REDIS_REST_TOKEN: "your-upstash-redis-token"
NEXT_PUBLIC_MARBLE_API_URL: "https://placeholder.example.com"
MARBLE_WORKSPACE_KEY: "placeholder"
FREESOUND_CLIENT_ID: "placeholder"
FREESOUND_API_KEY: "placeholder"
CLOUDFLARE_ACCOUNT_ID: "placeholder"
R2_ACCESS_KEY_ID: "placeholder"
R2_SECRET_ACCESS_KEY: "placeholder"
R2_BUCKET_NAME: "placeholder"
MODAL_TRANSCRIPTION_URL: "https://placeholder.example.com"
steps:
- name: Checkout repository
uses: actions/checkout@v4
+18 -18
View File
@@ -1,20 +1,20 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[javascript][typescript][javascriptreact][typescriptreact][json][jsonc][css][graphql]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"typescript.tsdk": "node_modules/typescript/lib",
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"emmet.showExpandedAbbreviation": "never",
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[javascript][typescript][javascriptreact][typescriptreact][json][jsonc][css][graphql]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"typescript.tsdk": "node_modules/typescript/lib",
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"emmet.showExpandedAbbreviation": "never",
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
+3 -1
View File
@@ -5,4 +5,6 @@
.env*
!.env.example
.next/
.next/
.font-cache/
+19 -19
View File
@@ -1,21 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+12 -12
View File
@@ -4,20 +4,20 @@ import { webEnv } from "@opencut/env/web";
// Load the right env file based on environment
if (webEnv.NODE_ENV === "production") {
dotenv.config({ path: ".env.production" });
dotenv.config({ path: ".env.production" });
} else {
dotenv.config({ path: ".env.local" });
dotenv.config({ path: ".env.local" });
}
export default {
schema: "./src/schema.ts",
dialect: "postgresql",
migrations: {
table: "drizzle_migrations",
},
dbCredentials: {
url: webEnv.DATABASE_URL,
},
out: "./migrations",
strict: webEnv.NODE_ENV === "production",
schema: "./src/schema.ts",
dialect: "postgresql",
migrations: {
table: "drizzle_migrations",
},
dbCredentials: {
url: webEnv.DATABASE_URL,
},
out: "./migrations",
strict: webEnv.NODE_ENV === "production",
} satisfies Config;
+342 -342
View File
@@ -1,344 +1,344 @@
{
"id": "33a6742f-89da-4ac5-958f-421aa1cf9bd6",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"accounts_user_id_users_id_fk": {
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sessions_token_unique": {
"name": "sessions_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.verifications": {
"name": "verifications",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.waitlist": {
"name": "waitlist",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"waitlist_email_unique": {
"name": "waitlist_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
"id": "33a6742f-89da-4ac5-958f-421aa1cf9bd6",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"accounts_user_id_users_id_fk": {
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sessions_token_unique": {
"name": "sessions_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.verifications": {
"name": "verifications",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.waitlist": {
"name": "waitlist",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"waitlist_email_unique": {
"name": "waitlist_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+11 -11
View File
@@ -1,13 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1750753385927,
"tag": "0000_brainy_saracen",
"breakpoints": true
}
]
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1750753385927,
"tag": "0000_brainy_saracen",
"breakpoints": true
}
]
}
+42 -42
View File
@@ -2,48 +2,48 @@ import type { NextConfig } from "next";
import { withBotId } from "botid/next/config";
const nextConfig: NextConfig = {
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
reactStrictMode: true,
productionBrowserSourceMaps: true,
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "plus.unsplash.com",
},
{
protocol: "https",
hostname: "images.unsplash.com",
},
{
protocol: "https",
hostname: "images.marblecms.com",
},
{
protocol: "https",
hostname: "lh3.googleusercontent.com",
},
{
protocol: "https",
hostname: "avatars.githubusercontent.com",
},
{
protocol: "https",
hostname: "api.iconify.design",
},
{
protocol: "https",
hostname: "api.simplesvg.com",
},
{
protocol: "https",
hostname: "api.unisvg.com",
},
],
},
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
reactStrictMode: true,
productionBrowserSourceMaps: true,
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "plus.unsplash.com",
},
{
protocol: "https",
hostname: "images.unsplash.com",
},
{
protocol: "https",
hostname: "images.marblecms.com",
},
{
protocol: "https",
hostname: "lh3.googleusercontent.com",
},
{
protocol: "https",
hostname: "avatars.githubusercontent.com",
},
{
protocol: "https",
hostname: "api.iconify.design",
},
{
protocol: "https",
hostname: "api.simplesvg.com",
},
{
protocol: "https",
hostname: "api.unisvg.com",
},
],
},
};
export default withBotId(nextConfig);
+105 -96
View File
@@ -1,98 +1,107 @@
{
"name": "@opencut/web",
"version": "0.1.0",
"private": true,
"packageManager": "bun@1.2.18",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "biome check src/",
"lint:fix": "biome check src/ --write",
"format": "biome format src/ --write",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
"db:push:prod": "cross-env NODE_ENV=production drizzle-kit push"
},
"dependencies": {
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "^3.9.1",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@upstash/ratelimit": "^2.0.6",
"@upstash/redis": "^1.35.4",
"@vercel/analytics": "^1.4.1",
"aws4fetch": "^1.0.20",
"better-auth": "^1.2.7",
"botid": "^1.4.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"dayjs": "^1.11.13",
"drizzle-orm": "^0.44.2",
"embla-carousel-react": "^8.5.1",
"eventemitter3": "^5.0.1",
"feed": "^5.1.0",
"input-otp": "^1.4.1",
"lucide-react": "^0.562.0",
"mediabunny": "^1.29.1",
"motion": "^12.18.1",
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.2",
"react": "^19.0.0",
"react-day-picker": "^8.10.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.0",
"react-icons": "^5.4.0",
"react-markdown": "^10.1.0",
"react-phone-number-input": "^3.4.11",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.14.1",
"rehype-autolink-headings": "^7.1.0",
"rehype-parse": "^9.0.1",
"rehype-sanitize": "^6.0.0",
"rehype-slug": "^6.0.0",
"rehype-stringify": "^10.0.1",
"sonner": "^1.7.1",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.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"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"@types/bun": "latest",
"@types/node": "^24.2.1",
"@types/pg": "^8.15.4",
"@types/react": "^19",
"@types/react-dom": "^19",
"cross-env": "^7.0.3",
"drizzle-kit": "^0.31.4",
"dotenv": "^16.5.0",
"postcss": "^8",
"tailwindcss": "^4.1.11",
"tsx": "^4.7.1",
"typescript": "^5.8.3"
}
"name": "@opencut/web",
"version": "0.1.0",
"private": true,
"packageManager": "bun@1.2.18",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "biome check src/",
"lint:fix": "biome check src/ --write",
"format": "biome format src/ --write",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
"db:push:prod": "cross-env NODE_ENV=production drizzle-kit push"
},
"dependencies": {
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "^3.9.1",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-primitive": "^2.1.4",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/culori": "^4.0.1",
"@upstash/ratelimit": "^2.0.6",
"@upstash/redis": "^1.35.4",
"@vercel/analytics": "^1.4.1",
"aws4fetch": "^1.0.20",
"better-auth": "^1.2.7",
"botid": "^1.4.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"culori": "^4.0.2",
"dayjs": "^1.11.13",
"drizzle-orm": "^0.44.2",
"embla-carousel-react": "^8.5.1",
"eventemitter3": "^5.0.1",
"feed": "^5.1.0",
"input-otp": "^1.4.1",
"lucide-react": "^0.562.0",
"mediabunny": "^1.29.1",
"motion": "^12.18.1",
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-day-picker": "^8.10.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.0",
"react-icons": "^5.4.0",
"react-markdown": "^10.1.0",
"react-phone-number-input": "^3.4.11",
"react-resizable-panels": "^2.1.7",
"react-window": "^2.2.7",
"recharts": "^2.14.1",
"rehype-autolink-headings": "^7.1.0",
"rehype-parse": "^9.0.1",
"rehype-sanitize": "^6.0.0",
"rehype-slug": "^6.0.0",
"rehype-stringify": "^10.0.1",
"sonner": "^1.7.1",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5",
"use-deep-compare-effect": "^1.8.1",
"vaul": "^1.1.2",
"wavesurfer.js": "^7.9.8",
"zod": "^3.25.67",
"zustand": "^5.0.2"
},
"devDependencies": {
"@napi-rs/canvas": "^0.1.92",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"@types/bun": "latest",
"@types/node": "^24.2.1",
"@types/pg": "^8.15.4",
"@types/react": "^19",
"@types/react-dom": "^19",
"cross-env": "^7.0.3",
"dotenv": "^16.5.0",
"drizzle-kit": "^0.31.4",
"postcss": "^8",
"sharp": "^0.34.5",
"tailwindcss": "^4.1.11",
"tsx": "^4.7.1",
"typescript": "^5.8.3"
}
}
+3 -3
View File
@@ -1,8 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 222 KiB

+42 -42
View File
@@ -1,44 +1,44 @@
{
"name": "OpenCut",
"description": "A simple but powerful video editor that gets the job done. In your browser.",
"display": "standalone",
"start_url": "/",
"icons": [
{
"src": "/icons/android-icon-36x36.png",
"sizes": "36x36",
"type": "image\/png",
"density": "0.75"
},
{
"src": "/icons/android-icon-48x48.png",
"sizes": "48x48",
"type": "image\/png",
"density": "1.0"
},
{
"src": "/icons/android-icon-72x72.png",
"sizes": "72x72",
"type": "image\/png",
"density": "1.5"
},
{
"src": "/icons/android-icon-96x96.png",
"sizes": "96x96",
"type": "image\/png",
"density": "2.0"
},
{
"src": "/icons/android-icon-144x144.png",
"sizes": "144x144",
"type": "image\/png",
"density": "3.0"
},
{
"src": "/icons/android-icon-192x192.png",
"sizes": "192x192",
"type": "image\/png",
"density": "4.0"
}
]
"name": "OpenCut",
"description": "A simple but powerful video editor that gets the job done. In your browser.",
"display": "standalone",
"start_url": "/",
"icons": [
{
"src": "/icons/android-icon-36x36.png",
"sizes": "36x36",
"type": "image\/png",
"density": "0.75"
},
{
"src": "/icons/android-icon-48x48.png",
"sizes": "48x48",
"type": "image\/png",
"density": "1.0"
},
{
"src": "/icons/android-icon-72x72.png",
"sizes": "72x72",
"type": "image\/png",
"density": "1.5"
},
{
"src": "/icons/android-icon-96x96.png",
"sizes": "96x96",
"type": "image\/png",
"density": "2.0"
},
{
"src": "/icons/android-icon-144x144.png",
"sizes": "144x144",
"type": "image\/png",
"density": "3.0"
},
{
"src": "/icons/android-icon-192x192.png",
"sizes": "192x192",
"type": "image\/png",
"density": "4.0"
}
]
}
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="44" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 126 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<polygon points="50,6 94,50 50,94 6,50" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 135 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<polygon points="28,8 72,8 94,50 72,92 28,92 6,50" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 146 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect x="10" y="10" width="80" height="80" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 138 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<polygon points="50,6 61,37 94,37 67,56 77,88 50,68 23,88 33,56 6,37 39,37" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 171 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<polygon points="50,8 94,92 6,92" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 129 B

+281
View File
@@ -0,0 +1,281 @@
/**
* Generates font sprite atlas for the font picker.
*
* Downloads Google Fonts from Fontsource, renders each font name as a sprite,
* packs them into chunk images, and outputs a JSON atlas + AVIF images.
*
* Run: npx tsx scripts/generate-font-sprites.ts
* Deps: @napi-rs/canvas sharp (install as devDependencies)
*/
import { createCanvas, GlobalFonts } from "@napi-rs/canvas";
import sharp from "sharp";
import { mkdir, writeFile, readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const FONT_SIZE = 24;
const ROW_HEIGHT = 40;
const CANVAS_WIDTH = 1200;
const MAX_CHUNK_HEIGHT = 800;
const PADDING_X = 14;
const WIDTH_BUFFER = 8;
const CONCURRENT_DOWNLOADS = 30;
const OUTPUT_DIR = join(__dirname, "..", "public", "fonts");
const CACHE_DIR = join(__dirname, "..", ".font-cache");
const FONTSOURCE_API = "https://api.fontsource.org/v1/fonts";
interface FontsourceFont {
id: string;
family: string;
subsets: string[];
weights: number[];
styles: string[];
category: string;
type: string;
}
interface MeasuredFont {
family: string;
width: number;
styles: string[];
}
interface PackedFont {
family: string;
x: number;
y: number;
w: number;
}
interface AtlasEntry {
x: number;
y: number;
w: number;
ch: number;
s: string[];
}
async function fetchFontList(): Promise<FontsourceFont[]> {
console.log("Fetching font list from Fontsource...");
const response = await fetch(FONTSOURCE_API);
if (!response.ok) throw new Error(`Fontsource API error: ${response.status}`);
const data: FontsourceFont[] = await response.json();
const filtered = data.filter(
(font) =>
font.type === "google" &&
font.subsets.includes("latin") &&
font.weights.includes(400),
);
console.log(
` ${filtered.length} Google fonts with Latin subset + 400 weight`,
);
return filtered;
}
async function downloadFont({ id }: { id: string }): Promise<Buffer | null> {
const cachePath = join(CACHE_DIR, `${id}.woff2`);
if (existsSync(cachePath)) {
return readFile(cachePath);
}
// Fontsource CDN serves woff2 for all Google fonts
const url = `https://cdn.jsdelivr.net/fontsource/fonts/${id}@latest/latin-400-normal.woff2`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const buffer = Buffer.from(await response.arrayBuffer());
await writeFile(cachePath, buffer);
return buffer;
} catch {
return null;
}
}
async function downloadAllFonts({
fonts,
concurrency,
}: {
fonts: FontsourceFont[];
concurrency: number;
}): Promise<Map<string, Buffer>> {
console.log(
`Downloading ${fonts.length} font files (${concurrency} concurrent)...`,
);
const results = new Map<string, Buffer>();
let nextIndex = 0;
let completed = 0;
async function worker() {
while (nextIndex < fonts.length) {
const index = nextIndex++;
const font = fonts[index];
const buffer = await downloadFont({ id: font.id });
if (buffer) results.set(font.id, buffer);
completed++;
if (completed % 100 === 0 || completed === fonts.length) {
process.stdout.write(`\r ${completed}/${fonts.length}`);
}
}
}
await Promise.all(Array.from({ length: concurrency }, () => worker()));
console.log(`\n Downloaded ${results.size}/${fonts.length} fonts`);
return results;
}
function measureFonts({
fonts,
fontBuffers,
}: {
fonts: FontsourceFont[];
fontBuffers: Map<string, Buffer>;
}): MeasuredFont[] {
console.log("Registering fonts and measuring text...");
const measured: MeasuredFont[] = [];
const canvas = createCanvas(CANVAS_WIDTH, ROW_HEIGHT);
const ctx = canvas.getContext("2d");
for (const font of fonts) {
const buffer = fontBuffers.get(font.id);
if (!buffer) continue;
try {
const ok = GlobalFonts.register(buffer, font.family);
if (!ok) continue;
ctx.font = `${FONT_SIZE}px "${font.family}"`;
const metrics = ctx.measureText(font.family);
const width = Math.ceil(metrics.width) + WIDTH_BUFFER;
const styles: string[] = [];
for (const weight of font.weights) {
if (font.styles.includes("normal")) styles.push(String(weight));
if (font.styles.includes("italic")) styles.push(`${weight}i`);
}
measured.push({ family: font.family, width, styles });
} catch {
// skip fonts that fail to register
}
}
measured.sort((a, b) => a.family.localeCompare(b.family));
console.log(` ${measured.length} fonts measured`);
return measured;
}
function packIntoChunks({ measured }: { measured: MeasuredFont[] }): {
atlas: Record<string, AtlasEntry>;
chunks: PackedFont[][];
} {
console.log("Packing into sprite chunks...");
const atlas: Record<string, AtlasEntry> = {};
const chunks: PackedFont[][] = [[]];
let chunkIndex = 0;
let cursorX = 0;
let cursorY = 0;
for (const font of measured) {
// New row if doesn't fit horizontally
if (cursorX + font.width > CANVAS_WIDTH) {
cursorX = 0;
cursorY += ROW_HEIGHT;
}
// New chunk if doesn't fit vertically
if (cursorY + ROW_HEIGHT > MAX_CHUNK_HEIGHT) {
chunkIndex++;
chunks.push([]);
cursorX = 0;
cursorY = 0;
}
// Same data goes to BOTH the atlas and the chunk render list
const x = cursorX;
const y = cursorY;
atlas[font.family] = {
x,
y,
w: font.width,
ch: chunkIndex,
s: font.styles,
};
chunks[chunkIndex].push({ family: font.family, x, y, w: font.width });
cursorX += font.width + PADDING_X;
}
console.log(` ${chunks.length} chunks`);
return { atlas, chunks };
}
async function renderChunks({
chunks,
}: {
chunks: PackedFont[][];
}): Promise<void> {
console.log("Rendering sprite chunks...");
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const chunkHeight = Math.max(...chunk.map((f) => f.y)) + ROW_HEIGHT;
const canvas = createCanvas(CANVAS_WIDTH, chunkHeight);
const ctx = canvas.getContext("2d");
for (const font of chunk) {
ctx.font = `${FONT_SIZE}px "${font.family}"`;
ctx.fillStyle = "#000000";
ctx.textBaseline = "middle";
ctx.fillText(font.family, font.x, font.y + ROW_HEIGHT / 2);
}
const pngBuffer = canvas.toBuffer("image/png");
await sharp(pngBuffer)
.avif({ quality: 80 })
.toFile(join(OUTPUT_DIR, `font-chunk-${i}.avif`));
console.log(
` Chunk ${i}: ${chunk.length} fonts, ${CANVAS_WIDTH}×${chunkHeight}`,
);
}
}
async function main() {
await mkdir(OUTPUT_DIR, { recursive: true });
await mkdir(CACHE_DIR, { recursive: true });
const fonts = await fetchFontList();
const fontBuffers = await downloadAllFonts({
fonts,
concurrency: CONCURRENT_DOWNLOADS,
});
const measured = measureFonts({ fonts, fontBuffers });
const { atlas, chunks } = packIntoChunks({ measured });
await renderChunks({ chunks });
// Write atlas JSON (compact for smaller file size)
await writeFile(
join(OUTPUT_DIR, "font-atlas.json"),
JSON.stringify({ fonts: atlas }),
);
const totalFonts = Object.keys(atlas).length;
console.log(
`\nDone! ${totalFonts} fonts in ${chunks.length} chunks → ${OUTPUT_DIR}`,
);
}
main().catch((error) => {
console.error("Failed:", error);
process.exit(1);
});
+280 -280
View File
@@ -1,280 +1,280 @@
import { webEnv } from "@opencut/env/web";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { checkRateLimit } from "@/lib/rate-limit";
const searchParamsSchema = z.object({
q: z.string().max(500, "Query too long").optional(),
type: z.enum(["songs", "effects"]).optional(),
page: z.coerce.number().int().min(1).max(1000).default(1),
page_size: z.coerce.number().int().min(1).max(150).default(20),
sort: z
.enum(["downloads", "rating", "created", "score"])
.default("downloads"),
min_rating: z.coerce.number().min(0).max(5).default(3),
commercial_only: z.coerce.boolean().default(true),
});
const freesoundResultSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
url: z.string().url(),
previews: z
.object({
"preview-hq-mp3": z.string().url(),
"preview-lq-mp3": z.string().url(),
"preview-hq-ogg": z.string().url(),
"preview-lq-ogg": z.string().url(),
})
.optional(),
download: z.string().url().optional(),
duration: z.number(),
filesize: z.number(),
type: z.string(),
channels: z.number(),
bitrate: z.number(),
bitdepth: z.number(),
samplerate: z.number(),
username: z.string(),
tags: z.array(z.string()),
license: z.string(),
created: z.string(),
num_downloads: z.number().optional(),
avg_rating: z.number().optional(),
num_ratings: z.number().optional(),
});
const freesoundResponseSchema = z.object({
count: z.number(),
next: z.string().url().nullable(),
previous: z.string().url().nullable(),
results: z.array(freesoundResultSchema),
});
const transformedResultSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
url: z.string(),
previewUrl: z.string().optional(),
downloadUrl: z.string().optional(),
duration: z.number(),
filesize: z.number(),
type: z.string(),
channels: z.number(),
bitrate: z.number(),
bitdepth: z.number(),
samplerate: z.number(),
username: z.string(),
tags: z.array(z.string()),
license: z.string(),
created: z.string(),
downloads: z.number().optional(),
rating: z.number().optional(),
ratingCount: z.number().optional(),
});
const apiResponseSchema = z.object({
count: z.number(),
next: z.string().nullable(),
previous: z.string().nullable(),
results: z.array(transformedResultSchema),
query: z.string().optional(),
type: z.string(),
page: z.number(),
pageSize: z.number(),
sort: z.string(),
minRating: z.number().optional(),
});
function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
if (!query) return `${sort}_desc`;
return sort === "score" ? "score" : `${sort}_desc`;
}
function applyEffectsFilters({
params,
min_rating,
commercial_only,
}: {
params: URLSearchParams;
min_rating: number;
commercial_only: boolean;
}) {
params.append("filter", "duration:[* TO 30.0]");
params.append("filter", `avg_rating:[${min_rating} TO *]`);
if (commercial_only) {
params.append(
"filter",
'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
);
}
params.append(
"filter",
"tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
);
}
function transformFreesoundResult(
result: z.infer<typeof freesoundResultSchema>,
) {
return {
id: result.id,
name: result.name,
description: result.description,
url: result.url,
previewUrl:
result.previews?.["preview-hq-mp3"] ||
result.previews?.["preview-lq-mp3"],
downloadUrl: result.download,
duration: result.duration,
filesize: result.filesize,
type: result.type,
channels: result.channels,
bitrate: result.bitrate,
bitdepth: result.bitdepth,
samplerate: result.samplerate,
username: result.username,
tags: result.tags,
license: result.license,
created: result.created,
downloads: result.num_downloads || 0,
rating: result.avg_rating || 0,
ratingCount: result.num_ratings || 0,
};
}
export async function GET(request: NextRequest) {
try {
const { limited } = await checkRateLimit({ request });
if (limited) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
const { searchParams } = new URL(request.url);
const validationResult = searchParamsSchema.safeParse({
q: searchParams.get("q") || undefined,
type: searchParams.get("type") || undefined,
page: searchParams.get("page") || undefined,
page_size: searchParams.get("page_size") || undefined,
sort: searchParams.get("sort") || undefined,
min_rating: searchParams.get("min_rating") || undefined,
});
if (!validationResult.success) {
return NextResponse.json(
{
error: "Invalid parameters",
details: validationResult.error.flatten().fieldErrors,
},
{ status: 400 },
);
}
const {
q: query,
type,
page,
page_size: pageSize,
sort,
min_rating,
commercial_only,
} = validationResult.data;
if (type === "songs") {
return NextResponse.json(
{
error: "Songs are not available yet",
message:
"Song search functionality is coming soon. Try searching for sound effects instead.",
},
{ status: 501 },
);
}
const baseUrl = "https://freesound.org/apiv2/search/text/";
const sortParam = buildSortParameter({ query, sort });
const params = new URLSearchParams({
query: query || "",
token: webEnv.FREESOUND_API_KEY,
page: page.toString(),
page_size: pageSize.toString(),
sort: sortParam,
fields:
"id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
});
const isEffectsSearch = type === "effects" || !type;
if (isEffectsSearch) {
applyEffectsFilters({ params, min_rating, commercial_only });
}
const response = await fetch(`${baseUrl}?${params.toString()}`);
if (!response.ok) {
const errorText = await response.text();
console.error("Freesound API error:", response.status, errorText);
return NextResponse.json(
{ error: "Failed to search sounds" },
{ status: response.status },
);
}
const rawData = await response.json();
const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
if (!freesoundValidation.success) {
console.error(
"Invalid Freesound API response:",
freesoundValidation.error,
);
return NextResponse.json(
{ error: "Invalid response from Freesound API" },
{ status: 502 },
);
}
const data = freesoundValidation.data;
const transformedResults = data.results.map(transformFreesoundResult);
const responseData = {
count: data.count,
next: data.next,
previous: data.previous,
results: transformedResults,
query: query || "",
type: type || "effects",
page,
pageSize,
sort,
minRating: min_rating,
};
const responseValidation = apiResponseSchema.safeParse(responseData);
if (!responseValidation.success) {
console.error(
"Invalid API response structure:",
responseValidation.error,
);
return NextResponse.json(
{ error: "Internal response formatting error" },
{ status: 500 },
);
}
return NextResponse.json(responseValidation.data);
} catch (error) {
console.error("Error searching sounds:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
import { webEnv } from "@opencut/env/web";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { checkRateLimit } from "@/lib/rate-limit";
const searchParamsSchema = z.object({
q: z.string().max(500, "Query too long").optional(),
type: z.enum(["songs", "effects"]).optional(),
page: z.coerce.number().int().min(1).max(1000).default(1),
page_size: z.coerce.number().int().min(1).max(150).default(20),
sort: z
.enum(["downloads", "rating", "created", "score"])
.default("downloads"),
min_rating: z.coerce.number().min(0).max(5).default(3),
commercial_only: z.coerce.boolean().default(true),
});
const freesoundResultSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
url: z.string().url(),
previews: z
.object({
"preview-hq-mp3": z.string().url(),
"preview-lq-mp3": z.string().url(),
"preview-hq-ogg": z.string().url(),
"preview-lq-ogg": z.string().url(),
})
.optional(),
download: z.string().url().optional(),
duration: z.number(),
filesize: z.number(),
type: z.string(),
channels: z.number(),
bitrate: z.number(),
bitdepth: z.number(),
samplerate: z.number(),
username: z.string(),
tags: z.array(z.string()),
license: z.string(),
created: z.string(),
num_downloads: z.number().optional(),
avg_rating: z.number().optional(),
num_ratings: z.number().optional(),
});
const freesoundResponseSchema = z.object({
count: z.number(),
next: z.string().url().nullable(),
previous: z.string().url().nullable(),
results: z.array(freesoundResultSchema),
});
const transformedResultSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
url: z.string(),
previewUrl: z.string().optional(),
downloadUrl: z.string().optional(),
duration: z.number(),
filesize: z.number(),
type: z.string(),
channels: z.number(),
bitrate: z.number(),
bitdepth: z.number(),
samplerate: z.number(),
username: z.string(),
tags: z.array(z.string()),
license: z.string(),
created: z.string(),
downloads: z.number().optional(),
rating: z.number().optional(),
ratingCount: z.number().optional(),
});
const apiResponseSchema = z.object({
count: z.number(),
next: z.string().nullable(),
previous: z.string().nullable(),
results: z.array(transformedResultSchema),
query: z.string().optional(),
type: z.string(),
page: z.number(),
pageSize: z.number(),
sort: z.string(),
minRating: z.number().optional(),
});
function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
if (!query) return `${sort}_desc`;
return sort === "score" ? "score" : `${sort}_desc`;
}
function applyEffectsFilters({
params,
min_rating,
commercial_only,
}: {
params: URLSearchParams;
min_rating: number;
commercial_only: boolean;
}) {
params.append("filter", "duration:[* TO 30.0]");
params.append("filter", `avg_rating:[${min_rating} TO *]`);
if (commercial_only) {
params.append(
"filter",
'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
);
}
params.append(
"filter",
"tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
);
}
function transformFreesoundResult(
result: z.infer<typeof freesoundResultSchema>,
) {
return {
id: result.id,
name: result.name,
description: result.description,
url: result.url,
previewUrl:
result.previews?.["preview-hq-mp3"] ||
result.previews?.["preview-lq-mp3"],
downloadUrl: result.download,
duration: result.duration,
filesize: result.filesize,
type: result.type,
channels: result.channels,
bitrate: result.bitrate,
bitdepth: result.bitdepth,
samplerate: result.samplerate,
username: result.username,
tags: result.tags,
license: result.license,
created: result.created,
downloads: result.num_downloads || 0,
rating: result.avg_rating || 0,
ratingCount: result.num_ratings || 0,
};
}
export async function GET(request: NextRequest) {
try {
const { limited } = await checkRateLimit({ request });
if (limited) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
const { searchParams } = new URL(request.url);
const validationResult = searchParamsSchema.safeParse({
q: searchParams.get("q") || undefined,
type: searchParams.get("type") || undefined,
page: searchParams.get("page") || undefined,
page_size: searchParams.get("page_size") || undefined,
sort: searchParams.get("sort") || undefined,
min_rating: searchParams.get("min_rating") || undefined,
});
if (!validationResult.success) {
return NextResponse.json(
{
error: "Invalid parameters",
details: validationResult.error.flatten().fieldErrors,
},
{ status: 400 },
);
}
const {
q: query,
type,
page,
page_size: pageSize,
sort,
min_rating,
commercial_only,
} = validationResult.data;
if (type === "songs") {
return NextResponse.json(
{
error: "Songs are not available yet",
message:
"Song search functionality is coming soon. Try searching for sound effects instead.",
},
{ status: 501 },
);
}
const baseUrl = "https://freesound.org/apiv2/search/text/";
const sortParam = buildSortParameter({ query, sort });
const params = new URLSearchParams({
query: query || "",
token: webEnv.FREESOUND_API_KEY,
page: page.toString(),
page_size: pageSize.toString(),
sort: sortParam,
fields:
"id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
});
const isEffectsSearch = type === "effects" || !type;
if (isEffectsSearch) {
applyEffectsFilters({ params, min_rating, commercial_only });
}
const response = await fetch(`${baseUrl}?${params.toString()}`);
if (!response.ok) {
const errorText = await response.text();
console.error("Freesound API error:", response.status, errorText);
return NextResponse.json(
{ error: "Failed to search sounds" },
{ status: response.status },
);
}
const rawData = await response.json();
const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
if (!freesoundValidation.success) {
console.error(
"Invalid Freesound API response:",
freesoundValidation.error,
);
return NextResponse.json(
{ error: "Invalid response from Freesound API" },
{ status: 502 },
);
}
const data = freesoundValidation.data;
const transformedResults = data.results.map(transformFreesoundResult);
const responseData = {
count: data.count,
next: data.next,
previous: data.previous,
results: transformedResults,
query: query || "",
type: type || "effects",
page,
pageSize,
sort,
minRating: min_rating,
};
const responseValidation = apiResponseSchema.safeParse(responseData);
if (!responseValidation.success) {
console.error(
"Invalid API response structure:",
responseValidation.error,
);
return NextResponse.json(
{ error: "Internal response formatting error" },
{ status: 500 },
);
}
return NextResponse.json(responseValidation.data);
} catch (error) {
console.error("Error searching sounds:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+8 -25
View File
@@ -88,18 +88,17 @@ function PostHeader({ post }: { post: Post }) {
});
return (
<>
{post.coverImage && <PostCoverImage post={post} />}
<div className="flex flex-col items-center justify-center gap-8">
<PostMeta date={formattedDate} publishedAt={post.publishedAt} />
<PostTitle title={post.title} />
<PostAuthor author={post.authors[0]} />
</>
{post.coverImage && <PostCoverImage post={post} />}
</div>
);
}
function PostCoverImage({ post }: { post: Post }) {
return (
<div className="relative aspect-video overflow-hidden rounded-lg">
<div className="relative aspect-video overflow-hidden rounded-lg w-full mt-4">
<Image
src={post.coverImage}
alt={post.title}
@@ -121,31 +120,15 @@ function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) {
function PostTitle({ title }: { title: string }) {
return (
<h1 className="text-5xl font-bold tracking-tight md:text-4xl">{title}</h1>
);
}
function PostAuthor({ author }: { author?: Author }) {
if (!author) return null;
return (
<div className="flex items-center justify-center gap-2">
<Image
src={author.image}
alt={author.name}
width={36}
height={36}
loading="eager"
className="aspect-square size-8 shrink-0 rounded-full"
/>
<p className="text-muted-foreground">{author.name}</p>
</div>
<h1 className="text-5xl font-bold tracking-tight md:text-4xl text-center">
{title}
</h1>
);
}
function PostContent({ html }: { html: string }) {
return (
<section className="pt-8">
<section className="">
<Prose html={html} />
</section>
);
+1 -22
View File
@@ -1,10 +1,9 @@
import type { Metadata } from "next";
import Link from "next/link";
import { BasePage } from "@/app/base-page";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import { getPosts } from "@/lib/blog/query";
import type { Author, Post } from "@/types/blog";
import type { Post } from "@/types/blog";
export const metadata: Metadata = {
title: "Blog - OpenCut",
@@ -47,27 +46,7 @@ function BlogPostItem({ post }: { post: Post }) {
<h2 className="text-xl font-semibold">{post.title}</h2>
<p className="text-muted-foreground">{post.description}</p>
</div>
{post.authors && post.authors.length > 0 && (
<AuthorList authors={post.authors} />
)}
</div>
</Link>
);
}
function AuthorList({ authors }: { authors: Author[] }) {
return (
<div className="flex items-center gap-2">
{authors.map((author) => (
<div key={author.id} className="flex items-center gap-2">
<Avatar className="size-6 shadow-sm">
<AvatarImage src={author.image} alt={author.name} />
<AvatarFallback className="text-xs">
{author.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</div>
))}
</div>
);
}
@@ -8,13 +8,14 @@ import {
} from "@/components/ui/resizable";
import { AssetsPanel } from "@/components/editor/panels/assets";
import { PropertiesPanel } from "@/components/editor/panels/properties";
import { Timeline } from "@/components/editor/timeline";
import { Timeline } from "@/components/editor/panels/timeline";
import { PreviewPanel } from "@/components/editor/panels/preview";
import { EditorHeader } from "@/components/editor/editor-header";
import { EditorProvider } from "@/components/providers/editor-provider";
import { Onboarding } from "@/components/editor/onboarding";
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
import { usePanelStore } from "@/stores/panel-store";
import { usePasteMedia } from "@/hooks/use-paste-media";
export default function Editor() {
const params = useParams();
@@ -35,6 +36,7 @@ export default function Editor() {
}
function EditorLayout() {
usePasteMedia();
const { panels, setPanel } = usePanelStore();
return (
@@ -65,7 +67,7 @@ function EditorLayout() {
defaultSize={panels.tools}
minSize={15}
maxSize={40}
className="min-w-0 rounded-sm"
className="min-w-0"
>
<AssetsPanel />
</ResizablePanel>
@@ -86,7 +88,7 @@ function EditorLayout() {
defaultSize={panels.properties}
minSize={15}
maxSize={40}
className="min-w-0 rounded-sm"
className="min-w-0"
>
<PropertiesPanel />
</ResizablePanel>
+81 -54
View File
@@ -13,22 +13,23 @@
--card: hsl(0, 0%, 100%);
--card-foreground: hsl(0 0% 11%);
--popover: hsl(0, 0%, 100%);
--popover-hover: hsl(0, 0%, 96%);
--popover-foreground: hsl(0 0% 2%);
--primary: hsl(203, 100%, 50%);
--primary-hover: hsl(203, 100%, 45%);
--primary: #009dff;
--primary-foreground: hsl(0, 0%, 100%);
--secondary: hsl(216 13% 94%);
--secondary-foreground: hsl(0 0% 2%);
--secondary: hsl(204, 100%, 97%);
--secondary-border: hsl(204, 100%, 94%);
--secondary-foreground: hsl(200, 98%, 39%);
--muted: hsl(0 0% 85.1%);
--muted-foreground: hsl(0 0% 50%);
--accent: hsl(216, 13%, 88%);
--accent: hsl(0, 0%, 96%);
--accent-foreground: hsl(0 0% 2%);
--destructive: hsl(0, 83%, 50%);
--destructive-foreground: hsl(0, 0%, 100%);
--constructive: hsl(141, 71%, 48%);
--constructive-foreground: hsl(0, 0%, 100%);
--border: hsl(0 0% 88%);
--input: hsl(0 0% 85.1%);
--border: hsl(0 0% 91%);
--input: hsl(0, 0%, 100%);
--ring: hsl(0, 0%, 55%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
@@ -43,49 +44,75 @@
--sidebar-accent-foreground: hsl(0 0% 2%);
--sidebar-border: hsl(0 0% 85.1%);
--sidebar-ring: hsl(0 0% 16.9%);
--panel-background: hsl(216 13% 94%);
--panel-accent: hsl(216, 8%, 88%);
--sidebar: hsl(0 0% 98%);
}
.dark {
--background: hsl(0 0% 4%);
--foreground: hsl(0 0% 89%);
--card: hsl(0 0% 4%);
--card-foreground: hsl(0 0% 89%);
--popover: hsl(0 0% 14.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(203, 100%, 50%);
--primary-hover: hsl(203, 100%, 45%);
--primary-foreground: hsl(0 0% 9%);
--secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(0 0% 14.9%);
--muted-foreground: hsl(0 0% 63.9%);
--accent: hsl(0, 0%, 28%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 83%, 55%);
--destructive-foreground: hsl(0 0% 98%);
.panel {
--background: hsl(216 13% 98%);
--foreground: hsl(0 0% 13%);
--card: hsl(0, 0%, 98%);
--card-foreground: hsl(0 0% 13%);
--primary-foreground: hsl(0, 0%, 98%);
--secondary: hsl(204, 100%, 95%);
--secondary-border: hsl(204, 100%, 92%);
--secondary-foreground: hsl(200, 98%, 37%);
--muted: hsl(0 0% 83.1%);
--muted-foreground: hsl(0 0% 48%);
--accent: hsl(0, 0%, 93%);
--accent-foreground: hsl(0 0% 5%);
--destructive: hsl(0, 83%, 50%);
--destructive-foreground: hsl(0, 0%, 98%);
--constructive: hsl(141, 71%, 48%);
--constructive-foreground: hsl(0 0% 100%);
--border: hsl(0 0% 17%);
--input: hsl(0 0% 14.9%);
--ring: hsl(0 0% 83.1%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(0 0% 3.9%);
--sidebar-foreground: hsl(0 0% 98%);
--sidebar-primary: hsl(0 0% 98%);
--sidebar-primary-foreground: hsl(0 0% 9%);
--sidebar-accent: hsl(0 0% 14.9%);
--sidebar-accent-foreground: hsl(0 0% 98%);
--sidebar-border: hsl(0 0% 14.9%);
--constructive-foreground: hsl(0, 0%, 98%);
--border: hsl(0 0% 89%);
--input: hsl(0 0% 93%);
--ring: hsl(0, 0%, 53%);
}
.dark {
--background: hsl(0, 0%, 5%);
--foreground: hsl(0 0% 87%);
--card: hsl(0, 0%, 5%);
--card-foreground: hsl(0 0% 87%);
--popover: hsl(0, 0%, 5%);
--popover-hover: hsl(0, 0%, 22%);
--popover-foreground: hsl(0 0% 95%);
--secondary: hsl(204, 100%, 12%);
--secondary-border: hsl(204, 100%, 15%);
--secondary-foreground: hsl(200, 98%, 61%);
--muted: hsl(0 0% 20%);
--accent: hsl(0, 0%, 14%);
--accent-foreground: hsl(0 0% 95%);
--constructive: hsl(141, 71%, 52%);
--border: hsl(0 0% 16%);
--input: hsl(0 0% 5%);
--ring: hsl(0, 0%, 50%);
--sidebar-background: hsl(0 0% 8%);
--sidebar-foreground: hsl(0 0% 95%);
--sidebar-primary: hsl(0 0% 95%);
--sidebar-primary-foreground: hsl(0 0% 15%);
--sidebar-accent: hsl(0 0% 20%);
--sidebar-accent-foreground: hsl(0 0% 95%);
--sidebar-border: hsl(0 0% 20%);
--sidebar-ring: hsl(0 0% 83.1%);
--panel-background: hsl(0 0% 11%);
--panel-accent: hsl(0 0% 15%);
--sidebar: hsl(240 5.9% 10%);
--sidebar: hsl(0 0% 6%);
}
.dark .panel {
--background: hsl(0 0% 10%);
--foreground: hsl(0 0% 85%);
--card: hsl(0, 0%, 10%);
--card-foreground: hsl(0 0% 85%);
--secondary: hsl(204, 100%, 12%);
--secondary-border: hsl(204, 100%, 17%);
--secondary-foreground: hsl(200, 98%, 63%);
--muted: hsl(0 0% 22%);
--accent: hsl(0, 0%, 15%);
--accent-foreground: hsl(0 0% 93%);
--constructive: hsl(141, 71%, 52%);
--border: hsl(0 0% 18%);
--input: hsl(0 0% 22%);
--ring: hsl(0, 0%, 52%);
}
@layer base {
@@ -113,6 +140,9 @@
/* Prevent back/forward swipe */
overscroll-behavior-x: contain;
}
::selection {
@apply bg-primary/35 selection:text-primary-foreground;
}
}
@theme inline {
@@ -123,10 +153,10 @@
--font-sans: var(--font-inter), sans-serif;
/* Font sizes */
--text-xl: 1.20rem;
--text-base: 0.92rem;
--text-xs: 0.72rem;
--text-sm: 0.79rem;
--text-base: 0.92rem;
--text-base--line-height: calc(1.5 / 0.95);
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.8);
/* Border radius */
@@ -142,12 +172,13 @@
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-hover: var(--popover-hover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary-hover: var(--primary-hover);
--color-secondary: var(--secondary);
--color-secondary-border: var(--secondary-border);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
@@ -183,10 +214,6 @@
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
/* Panel */
--color-panel: var(--panel-background);
--color-panel-accent: var(--panel-accent);
/* Animations */
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
+38 -53
View File
@@ -71,6 +71,11 @@ const formatProjectDuration = ({
return formatTimeCode({ timeInSeconds: duration, format });
};
const VIEW_MODE_OPTIONS = [
{ mode: "grid" as const, icon: GridViewIcon, label: "Grid view" },
{ mode: "list" as const, icon: LeftToRightListDashIcon, label: "List view" },
];
export default function ProjectsPage() {
const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore();
const editor = useEditor();
@@ -147,34 +152,23 @@ function ProjectsHeader() {
</BreadcrumbList>
</Breadcrumb>
<div className="hidden md:flex rounded-md border p-1 h-10">
<button
type="button"
className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "grid" ? "bg-accent/75" : ""}`}
onClick={() => setViewMode({ viewMode: "grid" })}
onKeyDown={(event) =>
event.key === "Enter" && setViewMode({ viewMode: "grid" })
}
aria-label="Grid view"
aria-pressed={isHydrated && viewMode === "grid"}
>
<HugeiconsIcon icon={GridViewIcon} className="size-4" />
</button>
<button
type="button"
className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "list" ? "bg-accent/75" : ""}`}
onClick={() => setViewMode({ viewMode: "list" })}
onKeyDown={(event) =>
event.key === "Enter" && setViewMode({ viewMode: "list" })
}
aria-label="List view"
aria-pressed={isHydrated && viewMode === "list"}
>
<HugeiconsIcon
icon={LeftToRightListDashIcon}
className="size-4"
/>
</button>
<div className="hidden md:flex items-center rounded-md border p-1 px-1.5 h-10">
{VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => (
<Button
key={mode}
variant="ghost"
size="icon"
className={cn(
"rounded-sm hover:bg-background",
isHydrated && viewMode === mode && "!bg-accent",
)}
onClick={() => setViewMode({ viewMode: mode })}
aria-label={label}
aria-pressed={isHydrated && viewMode === mode}
>
<HugeiconsIcon icon={icon} className="size-4" />
</Button>
))}
</div>
</div>
@@ -251,7 +245,6 @@ function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
</Button>
</SortDropdown>
<Button
type="button"
variant="text"
className="text-muted-foreground"
onClick={() =>
@@ -277,28 +270,21 @@ function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
<div className="h-4 w-px bg-border/50 block md:hidden" />
<div className="flex md:hidden items-center gap-4">
<Button
variant="text"
onClick={() => setViewMode({ viewMode: "grid" })}
>
<HugeiconsIcon
icon={GridViewIcon}
className={cn(
viewMode === "grid" ? "text-primary" : "text-muted-foreground",
)}
/>
</Button>
<Button
variant="text"
onClick={() => setViewMode({ viewMode: "list" })}
>
<HugeiconsIcon
icon={LeftToRightListDashIcon}
className={cn(
viewMode === "list" ? "text-primary" : "text-muted-foreground",
)}
/>
</Button>
{VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => (
<Button
key={mode}
variant="text"
onClick={() => setViewMode({ viewMode: mode })}
aria-label={label}
>
<HugeiconsIcon
icon={icon}
className={cn(
viewMode === mode ? "text-primary" : "text-muted-foreground",
)}
/>
</Button>
))}
</div>
</div>
{selectedProjectCount > 0 ? <ProjectActions /> : null}
@@ -781,7 +767,6 @@ function ProjectMenu({
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="background"
className={
isGrid
@@ -862,7 +847,7 @@ function ProjectsSkeleton() {
);
return (
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
<div className="px-4 xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{skeletonIds.map((skeletonId) => (
<Card
key={skeletonId}
+9 -3
View File
@@ -60,7 +60,7 @@ export const useProjectsStore = create<ProjectsState>()(
persist(
(set) => ({
searchQuery: "",
sortKey: "createdAt",
sortKey: "updatedAt",
sortOrder: "desc",
viewMode: "grid",
selectedProjectIds: [],
@@ -86,7 +86,9 @@ export const useProjectsStore = create<ProjectsState>()(
projectId,
isSelected,
}),
lastSelectedProjectId: isSelected ? projectId : state.lastSelectedProjectId,
lastSelectedProjectId: isSelected
? projectId
: state.lastSelectedProjectId,
})),
selectProjectRange: ({ projectId, allProjectIds }) =>
set((state) => {
@@ -120,7 +122,11 @@ export const useProjectsStore = create<ProjectsState>()(
}),
{
name: "projects-view-mode",
partialize: (state) => ({ viewMode: state.viewMode }),
partialize: (state) => ({
viewMode: state.viewMode,
sortKey: state.sortKey,
sortOrder: state.sortOrder,
}),
onRehydrateStorage: () => (state) => {
state?.setIsHydrated({ isHydrated: true });
},
@@ -122,8 +122,8 @@ export function EditableTimecode({
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className={cn(
"border-none bg-transparent font-mono text-xs outline-none",
"focus:bg-background focus:border-primary focus:rounded focus:border focus:px-1",
"-mx-1 border border-transparent bg-transparent px-1 font-mono text-xs outline-none",
"focus:bg-background focus:border-primary focus:rounded",
"text-primary tabular-nums",
hasError && "text-destructive focus:border-destructive",
className,
+101 -44
View File
@@ -1,8 +1,7 @@
"use client";
import { Button } from "../ui/button";
import { ChevronDown } from "lucide-react";
import { useState } from "react";
import { useRef, useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
@@ -17,23 +16,21 @@ import { useRouter } from "next/navigation";
import { FaDiscord } from "react-icons/fa6";
import { ExportButton } from "./export-button";
import { ThemeToggle } from "../theme-toggle";
import { SOCIAL_LINKS } from "@/constants/site-constants";
import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor";
import {
ArrowLeft02Icon,
Edit03Icon,
Delete02Icon,
CommandIcon,
} from "@hugeicons/core-free-icons";
import { CommandIcon, Logout05Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
import Image from "next/image";
import { cn } from "@/utils/ui";
export function EditorHeader() {
return (
<header className="bg-background flex h-[3.2rem] items-center justify-between px-3 pt-0.5">
<div className="flex items-center gap-2">
<header className="bg-background flex h-[3.4rem] items-center justify-between px-3 pt-0.5">
<div className="flex items-center gap-1">
<ProjectDropdown />
<EditableProjectName />
</div>
<nav className="flex items-center gap-2">
<ExportButton />
@@ -111,56 +108,40 @@ function ProjectDropdown() {
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
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?.metadata.name}
</span>
<Button variant="ghost" size="icon" className="p-1 rounded-sm size-8">
<Image
src={DEFAULT_LOGO_URL}
alt="Project thumbnail"
width={32}
height={32}
className="invert dark:invert-0 size-5"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-100 w-52">
<DropdownMenuContent align="start" className="z-100 w-44">
<DropdownMenuItem
className="flex items-center gap-1.5"
onClick={handleExit}
disabled={isExiting}
icon={<HugeiconsIcon icon={Logout05Icon} />}
>
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
Exit project
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-1.5"
onClick={() => setOpenDialog("rename")}
>
<HugeiconsIcon icon={Edit03Icon} className="size-4" />
Rename project
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
className="flex items-center gap-1.5"
onClick={() => setOpenDialog("delete")}
>
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
Delete project
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex items-center gap-1.5"
onClick={() => setOpenDialog("shortcuts")}
icon={<HugeiconsIcon icon={CommandIcon} />}
>
<HugeiconsIcon icon={CommandIcon} className="size-4" />
Keyboard shortcuts
Shortcuts
</DropdownMenuItem>
<DropdownMenuItem asChild>
<DropdownMenuSeparator />
<DropdownMenuItem asChild icon={<FaDiscord className="!size-4" />}>
<Link
href={SOCIAL_LINKS.discord}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5"
>
<FaDiscord className="size-4" />
Discord
</Link>
</DropdownMenuItem>
@@ -185,3 +166,79 @@ function ProjectDropdown() {
</>
);
}
function EditableProjectName() {
const editor = useEditor();
const activeProject = editor.project.getActive();
const [isEditing, setIsEditing] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const originalNameRef = useRef("");
const projectName = activeProject?.metadata.name || "";
const startEditing = () => {
if (isEditing) return;
originalNameRef.current = projectName;
setIsEditing(true);
requestAnimationFrame(() => {
inputRef.current?.select();
});
};
const saveEdit = async () => {
if (!inputRef.current || !activeProject) return;
const newName = inputRef.current.value.trim();
setIsEditing(false);
if (!newName) {
inputRef.current.value = originalNameRef.current;
return;
}
if (newName !== originalNameRef.current) {
try {
await editor.project.renameProject({
id: activeProject.metadata.id,
name: newName,
});
} catch (error) {
toast.error("Failed to rename project", {
description:
error instanceof Error ? error.message : "Please try again",
});
}
}
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
inputRef.current?.blur();
} else if (event.key === "Escape") {
event.preventDefault();
if (inputRef.current) {
inputRef.current.value = originalNameRef.current;
}
setIsEditing(false);
inputRef.current?.blur();
}
};
return (
<input
ref={inputRef}
type="text"
defaultValue={projectName}
readOnly={!isEditing}
onClick={startEditing}
onBlur={saveEdit}
onKeyDown={handleKeyDown}
style={{ fieldSizing: "content" }}
className={cn(
"text-[0.9rem] h-8 px-2 py-1 rounded-sm bg-transparent outline-none cursor-pointer hover:bg-accent hover:text-accent-foreground",
isEditing && "ring-1 ring-ring cursor-text hover:bg-transparent",
)}
/>
);
}
+91 -101
View File
@@ -15,7 +15,7 @@ import { Progress } from "@/components/ui/progress";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/utils/ui";
import { getExportMimeType, getExportFileExtension } from "@/lib/export";
import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
import { Check, Copy, Download, RotateCcw } from "lucide-react";
import {
EXPORT_FORMAT_VALUES,
EXPORT_QUALITY_VALUES,
@@ -23,7 +23,11 @@ import {
type ExportQuality,
type ExportResult,
} from "@/types/export";
import { PropertyGroup } from "@/components/editor/panels/properties/property-item";
import {
Section,
SectionContent,
SectionHeader,
} from "@/components/editor/panels/properties/section";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
@@ -44,9 +48,7 @@ export function ExportButton() {
type="button"
className={cn(
"flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white",
hasProject
? "cursor-pointer hover:brightness-105"
: "cursor-not-allowed opacity-50",
hasProject ? "cursor-pointer" : "cursor-not-allowed opacity-50",
)}
onClick={hasProject ? handleExport : undefined}
disabled={!hasProject}
@@ -141,20 +143,12 @@ function ExportPopover({
}
};
const handleClose = () => {
if (!isExporting) {
onOpenChange(false);
setExportResult(null);
setProgress(0);
}
};
const handleCancel = () => {
cancelRequestedRef.current = true;
};
return (
<PopoverContent className="bg-background mr-4 flex w-80 flex-col gap-3">
<PopoverContent className="bg-background mr-4 flex w-80 flex-col p-0">
{exportResult && !exportResult.success ? (
<ExportError
error={exportResult.error || "Unknown error occurred"}
@@ -162,110 +156,106 @@ function ExportPopover({
/>
) : (
<>
<div className="flex items-center justify-between">
<h3 className="font-medium">
<div className="flex items-center justify-between p-3 border-b">
<h3 className="font-medium text-sm">
{isExporting ? "Exporting project" : "Export project"}
</h3>
<Button variant="text" size="icon" onClick={handleClose}>
<X className="text-foreground/85 !size-5" />
</Button>
</div>
<div className="flex flex-col gap-4">
{!isExporting && (
<>
<div className="flex flex-col gap-3">
<PropertyGroup
title="Format"
titleClassName="text-sm"
defaultExpanded={false}
>
<RadioGroup
value={format}
onValueChange={(value) => {
if (isExportFormat(value)) {
setFormat(value);
}
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="mp4" id="mp4" />
<Label htmlFor="mp4">
MP4 (H.264) - Better compatibility
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="webm" id="webm" />
<Label htmlFor="webm">
WebM (VP9) - Smaller file size
</Label>
</div>
</RadioGroup>
</PropertyGroup>
<div className="flex flex-col">
<Section collapsible defaultOpen={false} hasBorderTop={false}>
<SectionHeader title="Format" />
<SectionContent>
<RadioGroup
value={format}
onValueChange={(value) => {
if (isExportFormat(value)) {
setFormat(value);
}
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="mp4" id="mp4" />
<Label htmlFor="mp4">
MP4 (H.264) - Better compatibility
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="webm" id="webm" />
<Label htmlFor="webm">
WebM (VP9) - Smaller file size
</Label>
</div>
</RadioGroup>
</SectionContent>
</Section>
<PropertyGroup
title="Quality"
titleClassName="text-sm"
defaultExpanded={false}
>
<RadioGroup
value={quality}
onValueChange={(value) => {
if (isExportQuality(value)) {
setQuality(value);
}
}}
>
<Section collapsible defaultOpen={false}>
<SectionHeader title="Quality" />
<SectionContent>
<RadioGroup
value={quality}
onValueChange={(value) => {
if (isExportQuality(value)) {
setQuality(value);
}
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="low" id="low" />
<Label htmlFor="low">Low - Smallest file size</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="medium" id="medium" />
<Label htmlFor="medium">Medium - Balanced</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="high" id="high" />
<Label htmlFor="high">High - Recommended</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="very_high" id="very_high" />
<Label htmlFor="very_high">
Very High - Largest file size
</Label>
</div>
</RadioGroup>
</SectionContent>
</Section>
<Section collapsible defaultOpen={false}>
<SectionHeader title="Audio" />
<SectionContent>
<div className="flex items-center space-x-2">
<RadioGroupItem value="low" id="low" />
<Label htmlFor="low">Low - Smallest file size</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="medium" id="medium" />
<Label htmlFor="medium">Medium - Balanced</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="high" id="high" />
<Label htmlFor="high">High - Recommended</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="very_high" id="very_high" />
<Label htmlFor="very_high">
Very High - Largest file size
<Checkbox
id="include-audio"
checked={includeAudio}
onCheckedChange={(checked) =>
setIncludeAudio(!!checked)
}
/>
<Label htmlFor="include-audio">
Include audio in export
</Label>
</div>
</RadioGroup>
</PropertyGroup>
<PropertyGroup
title="Audio"
titleClassName="text-sm"
defaultExpanded={false}
>
<div className="flex items-center space-x-2">
<Checkbox
id="include-audio"
checked={includeAudio}
onCheckedChange={(checked) =>
setIncludeAudio(!!checked)
}
/>
<Label htmlFor="include-audio">
Include audio in export
</Label>
</div>
</PropertyGroup>
</SectionContent>
</Section>
</div>
<Button onClick={handleExport} className="w-full gap-2">
<Download className="size-4" />
Export
</Button>
<div className="p-3 pt-0">
<Button onClick={handleExport} className="w-full gap-2">
<Download className="size-4" />
Export
</Button>
</div>
</>
)}
{isExporting && (
<div className="space-y-4">
<div className="space-y-4 p-3">
<div className="flex flex-col">
<div className="flex items-center justify-between text-center">
<p className="text-muted-foreground mb-2 text-sm">
@@ -110,7 +110,7 @@ export function DraggableItem({
<AspectRatio
ratio={aspectRatio}
className={cn(
"bg-panel-accent relative overflow-hidden",
"bg-accent relative overflow-hidden",
isRounded && "rounded-sm",
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
)}
@@ -163,7 +163,9 @@ export function DraggableItem({
<div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
{preview}
</div>
<span className="w-full flex-1 truncate text-sm text-left">{name}</span>
<span className="w-full flex-1 truncate text-sm text-left">
{name}
</span>
</button>
</div>
)}
@@ -215,7 +217,7 @@ function PlusButton({
<Button
size="icon"
className={cn(
"bg-background hover:bg-panel text-foreground absolute right-2 bottom-2 size-5",
"bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
className,
)}
onClick={(e) => {
@@ -4,7 +4,7 @@ import { Separator } from "@/components/ui/separator";
import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store";
import { TabBar } from "./tabbar";
import { Captions } from "./views/captions";
import { MediaView } from "./views/media";
import { MediaView } from "./views/assets";
import { SettingsView } from "./views/settings";
import { SoundsView } from "./views/sounds";
import { StickersView } from "./views/stickers";
@@ -43,7 +43,7 @@ export function AssetsPanel() {
};
return (
<div className="bg-panel flex h-full">
<div className="panel bg-background flex h-full rounded-sm border overflow-hidden">
<TabBar />
<Separator orientation="vertical" />
<div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div>
@@ -6,6 +6,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { cn } from "@/utils/ui";
import {
TAB_KEYS,
@@ -15,9 +16,9 @@ import {
export function TabBar() {
const { activeTab, setActiveTab } = useAssetsPanelStore();
const scrollRef = useRef<HTMLDivElement>(null);
const [showTopFade, setShowTopFade] = useState(false);
const [showBottomFade, setShowBottomFade] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const checkScrollPosition = useCallback(() => {
const element = scrollRef.current;
@@ -48,32 +49,25 @@ export function TabBar() {
<div className="relative flex">
<div
ref={scrollRef}
className="scrollbar-hidden relative flex size-full flex-col items-center justify-start gap-5 overflow-y-auto px-4 py-4"
className="scrollbar-hidden relative flex size-full p-2 flex-col items-center justify-start gap-1.5 overflow-y-auto"
>
{TAB_KEYS.map((tabKey) => {
const tab = tabs[tabKey];
return (
<Tooltip key={tabKey} delayDuration={10}>
<TooltipTrigger asChild>
<button
type="button"
<Button
variant={activeTab === tabKey ? "secondary" : "text"}
aria-label={tab.label}
className={cn(
"flex cursor-pointer flex-col items-center gap-0.5 [&>svg]:size-4.5! opacity-100 hover:opacity-75",
activeTab === tabKey
? "text-primary !opacity-100"
: "text-muted-foreground",
"flex-col !p-1.5 !rounded-sm !h-auto [&_svg]:size-4.5",
activeTab !== tabKey &&
"border border-transparent text-muted-foreground",
)}
onClick={() => setActiveTab(tabKey)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setActiveTab(tabKey);
}
}}
>
<tab.icon className=" " />
</button>
<tab.icon />
</Button>
</TooltipTrigger>
<TooltipContent
side="right"
@@ -108,8 +102,8 @@ function FadeOverlay({
className={cn(
"pointer-events-none absolute right-0 left-0 h-6",
direction === "top" && show
? "from-panel top-0 bg-gradient-to-b to-transparent"
: "from-panel bottom-0 bg-gradient-to-t to-transparent",
? "from-background top-0 bg-gradient-to-b to-transparent"
: "from-background bottom-0 bg-gradient-to-t to-transparent",
)}
/>
);
@@ -3,6 +3,7 @@
import Image from "next/image";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay";
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
import { Button } from "@/components/ui/button";
@@ -29,14 +30,9 @@ 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";
import {
buildImageElement,
buildUploadAudioElement,
buildVideoElement,
} from "@/lib/timeline/element-utils";
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
import type { MediaAsset } from "@/types/assets";
import type { CreateTimelineElement } from "@/types/timeline";
import { cn } from "@/utils/ui";
import {
CloudUploadIcon,
@@ -132,7 +128,15 @@ export function MediaView() {
asset: MediaAsset;
startTime: number;
}): boolean => {
const element = createElementFromMedia({ asset, startTime });
const duration =
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
const element = buildElementFromMedia({
mediaId: asset.id,
mediaType: asset.type,
name: asset.name,
duration,
startTime,
});
editor.timeline.insertElement({
element,
placement: { mode: "auto" },
@@ -194,171 +198,166 @@ export function MediaView() {
const renderCompactPreview = (item: MediaAsset) =>
previewComponents.get(`compact-${item.id}`);
const mediaActions = (
<div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={() =>
setMediaViewMode(mediaViewMode === "grid" ? "list" : "grid")
}
disabled={isProcessing}
className="items-center justify-center"
>
{mediaViewMode === "grid" ? (
<HugeiconsIcon icon={LeftToRightListDashIcon} />
) : (
<HugeiconsIcon icon={GridViewIcon} />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{mediaViewMode === "grid"
? "Switch to list view"
: "Switch to grid view"}
</p>
</TooltipContent>
<Tooltip>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
disabled={isProcessing}
className="items-center justify-center"
>
<HugeiconsIcon icon={SortingOneNineIcon} />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end">
<SortMenuItem
label="Name"
sortKey="name"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
<SortMenuItem
label="Type"
sortKey="type"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
<SortMenuItem
label="Duration"
sortKey="duration"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
<SortMenuItem
label="File size"
sortKey="size"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>
<p>
Sort by {sortBy} (
{sortOrder === "asc" ? "ascending" : "descending"})
</p>
</TooltipContent>
</Tooltip>
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
onClick={openFilePicker}
disabled={isProcessing}
size="sm"
className="items-center justify-center gap-1.5 ml-1.5"
>
<HugeiconsIcon icon={CloudUploadIcon} />
Import
</Button>
</div>
);
return (
<>
<input {...fileInputProps} />
<div
className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`}
<PanelView
title="Assets"
actions={mediaActions}
className={isDragOver ? "bg-accent/30" : ""}
{...dragProps}
>
<div className="bg-panel py-2 px-4 flex items-center justify-between border-b">
<span className="text-muted-foreground text-sm">Assets</span>
<div className="flex items-center gap-0">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="text"
onClick={() =>
setMediaViewMode(
mediaViewMode === "grid" ? "list" : "grid",
)
}
disabled={isProcessing}
className="items-center justify-center"
>
{mediaViewMode === "grid" ? (
<HugeiconsIcon icon={LeftToRightListDashIcon} />
) : (
<HugeiconsIcon icon={GridViewIcon} />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{mediaViewMode === "grid"
? "Switch to list view"
: "Switch to grid view"}
</p>
</TooltipContent>
<Tooltip>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="text"
disabled={isProcessing}
className="items-center justify-center"
>
<HugeiconsIcon icon={SortingOneNineIcon} />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end">
<SortMenuItem
label="Name"
sortKey="name"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
<SortMenuItem
label="Type"
sortKey="type"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
<SortMenuItem
label="Duration"
sortKey="duration"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
<SortMenuItem
label="File size"
sortKey="size"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>
<p>
Sort by {sortBy} (
{sortOrder === "asc" ? "ascending" : "descending"})
</p>
</TooltipContent>
</Tooltip>
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
onClick={openFilePicker}
disabled={isProcessing}
size="sm"
className="items-center justify-center gap-1.5 ml-1.5"
>
<HugeiconsIcon icon={CloudUploadIcon} />
Upload
</Button>
</div>
</div>
<div className="scrollbar-thin size-full overflow-y-auto pt-1">
<div className="w-full flex-1 p-3 pt-0">
{isDragOver || filteredMediaItems.length === 0 ? (
<MediaDragOverlay
isVisible={true}
isProcessing={isProcessing}
progress={progress}
onClick={openFilePicker}
/>
) : mediaViewMode === "grid" ? (
<GridView
items={filteredMediaItems}
renderPreview={renderPreview}
onRemove={handleRemove}
onAddToTimeline={addElementAtTime}
highlightedId={highlightedId}
registerElement={registerElement}
/>
) : (
<ListView
items={filteredMediaItems}
renderPreview={renderCompactPreview}
onRemove={handleRemove}
onAddToTimeline={addElementAtTime}
highlightedId={highlightedId}
registerElement={registerElement}
/>
)}
</div>
</div>
</div>
{isDragOver || filteredMediaItems.length === 0 ? (
<MediaDragOverlay
isVisible={true}
isProcessing={isProcessing}
progress={progress}
onClick={openFilePicker}
/>
) : mediaViewMode === "grid" ? (
<GridView
items={filteredMediaItems}
renderPreview={renderPreview}
onRemove={handleRemove}
onAddToTimeline={addElementAtTime}
highlightedId={highlightedId}
registerElement={registerElement}
/>
) : (
<ListView
items={filteredMediaItems}
renderPreview={renderCompactPreview}
onRemove={handleRemove}
onAddToTimeline={addElementAtTime}
highlightedId={highlightedId}
registerElement={registerElement}
/>
)}
</PanelView>
</>
);
}
@@ -636,40 +635,3 @@ function SortMenuItem({
</DropdownMenuItem>
);
}
function createElementFromMedia({
asset,
startTime,
}: {
asset: MediaAsset;
startTime: number;
}): CreateTimelineElement {
const duration =
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
switch (asset.type) {
case "video":
return buildVideoElement({
mediaId: asset.id,
name: asset.name,
duration,
startTime,
});
case "image":
return buildImageElement({
mediaId: asset.id,
name: asset.name,
duration,
startTime,
});
case "audio":
return buildUploadAudioElement({
mediaId: asset.id,
name: asset.name,
duration,
startTime,
});
default:
throw new Error(`Unsupported media type: ${asset.type}`);
}
}
@@ -0,0 +1,52 @@
import { cn } from "@/utils/ui";
interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> {
title?: string;
actions?: React.ReactNode;
children: React.ReactNode;
contentClassName?: string;
hideHeader?: boolean;
ref?: React.Ref<HTMLDivElement>;
onScroll?: React.UIEventHandler<HTMLDivElement>;
scrollRef?: React.Ref<HTMLDivElement>;
}
export function PanelView({
title,
actions,
children,
className,
contentClassName,
hideHeader = false,
ref,
onScroll,
scrollRef,
...rest
}: PanelViewProps) {
return (
<div
className={cn("relative flex h-full flex-col", className)}
ref={ref}
{...rest}
>
{!hideHeader && (
<div className="bg-background h-11 shrink-0 px-4 pr-2 flex items-center justify-between border-b">
<span className="text-muted-foreground text-sm">{title}</span>
{actions}
</div>
)}
<div
className={cn(
"scrollbar-thin size-full overflow-y-auto",
hideHeader ? "pt-4" : "pt-2",
)}
ref={scrollRef}
onScroll={onScroll}
>
<div className={cn("w-full flex-1 px-2 pt-0", contentClassName)}>
{children}
</div>
</div>
</div>
);
}
@@ -1,6 +1,5 @@
import { Button } from "@/components/ui/button";
import { PropertyGroup } from "@/components/editor/panels/properties/property-item";
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import {
Select,
SelectContent,
@@ -21,6 +20,7 @@ import { transcriptionService } from "@/services/transcription/service";
import { decodeAudioToFloat32 } from "@/lib/media/audio";
import { buildCaptionChunks } from "@/lib/transcription/caption";
import { Spinner } from "@/components/ui/spinner";
import { Label } from "@/components/ui/label";
export function Captions() {
const [selectedLanguage, setSelectedLanguage] =
@@ -108,16 +108,14 @@ export function Captions() {
};
return (
<BaseView
ref={containerRef}
className="flex h-full flex-col justify-between"
>
<PropertyGroup title="Language">
<PanelView title="Captions" ref={containerRef}>
<div className="flex flex-col gap-3">
<Label>Language</Label>
<Select
value={selectedLanguage}
onValueChange={(value) => handleLanguageChange({ value })}
>
<SelectTrigger className="bg-panel-accent h-8 w-full text-xs">
<SelectTrigger>
<SelectValue placeholder="Select a language" />
</SelectTrigger>
<SelectContent>
@@ -129,7 +127,7 @@ export function Captions() {
))}
</SelectContent>
</Select>
</PropertyGroup>
</div>
<div className="flex flex-col gap-4">
{error && (
@@ -147,6 +145,6 @@ export function Captions() {
{isProcessing ? processingStep : "Generate transcript"}
</Button>
</div>
</BaseView>
</PanelView>
);
}
@@ -0,0 +1,332 @@
// @ts-nocheck
"use client";
import Image from "next/image";
import { memo, useCallback, useMemo } from "react";
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
BLUR_INTENSITY_PRESETS,
DEFAULT_BLUR_INTENSITY,
DEFAULT_COLOR,
FPS_PRESETS,
} from "@/constants/project-constants";
import { patternCraftGradients } from "@/data/colors/pattern-craft";
import { colors } from "@/data/colors/solid";
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
import { useEditor } from "@/hooks/use-editor";
import { useEditorStore } from "@/stores/editor-store";
import { dimensionToAspectRatio } from "@/utils/geometry";
import { cn } from "@/utils/ui";
import {
PropertyGroup,
PropertyItem,
PropertyItemLabel,
PropertyItemValue,
} from "@/components/editor/panels/properties/section";
export function SettingsView() {
return (
<PanelView title="Project settings" contentClassName="px-0">
<div className="flex flex-col gap-8">
<ProjectInfoView />
<BackgroundView />
</div>
</PanelView>
);
}
function ProjectInfoView() {
const editor = useEditor();
const activeProject = editor.project.getActive();
const { canvasPresets } = useEditorStore();
const findPresetIndexByAspectRatio = ({
presets,
targetAspectRatio,
}: {
presets: Array<{ width: number; height: number }>;
targetAspectRatio: string;
}) => {
for (let index = 0; index < presets.length; index++) {
const preset = presets[index];
const presetAspectRatio = dimensionToAspectRatio({
width: preset.width,
height: preset.height,
});
if (presetAspectRatio === targetAspectRatio) {
return index;
}
}
return -1;
};
const currentCanvasSize = activeProject.settings.canvasSize;
const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
const presetIndex = findPresetIndexByAspectRatio({
presets: canvasPresets,
targetAspectRatio: currentAspectRatio,
});
const originalPresetValue = "original";
const selectedPresetValue =
presetIndex !== -1 ? presetIndex.toString() : originalPresetValue;
const handleAspectRatioChange = ({ value }: { value: string }) => {
if (value === originalPresetValue) {
const canvasSize = originalCanvasSize ?? currentCanvasSize;
editor.project.updateSettings({
settings: { canvasSize },
});
return;
}
const index = parseInt(value, 10);
const preset = canvasPresets[index];
if (preset) {
editor.project.updateSettings({ settings: { canvasSize: preset } });
}
};
const handleFpsChange = (value: string) => {
const fps = parseFloat(value);
editor.project.updateSettings({ settings: { fps } });
};
return (
<div className="flex flex-col gap-4">
<PropertyItem direction="column">
<PropertyItemLabel>Name</PropertyItemLabel>
<PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Aspect ratio</PropertyItemLabel>
<PropertyItemValue>
<Select
value={selectedPresetValue}
onValueChange={(value) => handleAspectRatioChange({ value })}
>
<SelectTrigger>
<SelectValue placeholder="Select an aspect ratio" />
</SelectTrigger>
<SelectContent>
<SelectItem value={originalPresetValue}>Original</SelectItem>
{canvasPresets.map((preset, index) => {
const label = dimensionToAspectRatio({
width: preset.width,
height: preset.height,
});
return (
<SelectItem key={label} value={index.toString()}>
{label}
</SelectItem>
);
})}
</SelectContent>
</Select>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Frame rate</PropertyItemLabel>
<PropertyItemValue>
<Select
value={activeProject.settings.fps.toString()}
onValueChange={handleFpsChange}
>
<SelectTrigger>
<SelectValue placeholder="Select a frame rate" />
</SelectTrigger>
<SelectContent>
{FPS_PRESETS.map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
</PropertyItemValue>
</PropertyItem>
</div>
);
}
const BlurPreview = memo(
({
blur,
isSelected,
onSelect,
}: {
blur: { label: string; value: number };
isSelected: boolean;
onSelect: () => void;
}) => (
<button
className={cn(
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
isSelected && "border-primary border-2",
)}
onClick={onSelect}
type="button"
aria-label={`Select ${blur.label} blur`}
>
<Image
src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
alt={`Blur preview ${blur.label}`}
fill
className="object-cover"
style={{ filter: `blur(${blur.value}px)` }}
loading="eager"
/>
<div className="absolute right-1 bottom-1 left-1 text-center">
<span className="rounded bg-black/50 px-1 text-xs text-white">
{blur.label}
</span>
</div>
</button>
),
);
BlurPreview.displayName = "BlurPreview";
const BackgroundPreviews = memo(
({
backgrounds,
currentBackgroundColor,
isColorBackground,
handleColorSelect,
useBackgroundColor = false,
}: {
backgrounds: string[];
currentBackgroundColor: string;
isColorBackground: boolean;
handleColorSelect: ({ bg }: { bg: string }) => void;
useBackgroundColor?: boolean;
}) => {
return useMemo(
() =>
backgrounds.map((bg, index) => (
<button
key={`${index}-${bg}`}
className={cn(
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
isColorBackground &&
bg === currentBackgroundColor &&
"border-primary border-2",
)}
style={
useBackgroundColor
? { backgroundColor: bg }
: {
background: bg,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}
}
onClick={() => handleColorSelect({ bg })}
type="button"
aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`}
/>
)),
[
backgrounds,
isColorBackground,
currentBackgroundColor,
handleColorSelect,
useBackgroundColor,
],
);
},
);
BackgroundPreviews.displayName = "BackgroundPreviews";
function BackgroundView() {
const editor = useEditor();
const activeProject = editor.project.getActive();
const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
const handleBlurSelect = useCallback(
async ({ blurIntensity }: { blurIntensity: number }) => {
await editor.project.updateSettings({
settings: { background: { type: "blur", blurIntensity } },
});
},
[editor.project],
);
const handleColorSelect = useCallback(
async ({ color }: { color: string }) => {
await editor.project.updateSettings({
settings: { background: { type: "color", color } },
});
},
[editor.project],
);
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(
() =>
blurLevels.map((blur) => (
<BlurPreview
key={blur.value}
blur={blur}
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
onSelect={() => handleBlurSelect({ blurIntensity: blur.value })}
/>
)),
[blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
);
const backgroundSections = [
{ title: "Colors", backgrounds: colors, useBackgroundColor: true },
{ title: "Pattern craft", backgrounds: patternCraftGradients },
{ title: "Syntax UI", backgrounds: syntaxUIGradients },
];
return (
<div className="flex flex-col">
<PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}>
<div className="flex flex-wrap gap-2">{blurPreviews}</div>
</PropertyGroup>
{backgroundSections.map((section) => (
<PropertyGroup
key={section.title}
title={section.title}
defaultExpanded={false}
>
<div className="flex flex-wrap gap-2">
<BackgroundPreviews
backgrounds={section.backgrounds}
currentBackgroundColor={currentBackgroundColor}
isColorBackground={isColorBackground}
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
useBackgroundColor={section.useBackgroundColor}
/>
</div>
</PropertyGroup>
))}
</div>
);
}
@@ -1,8 +1,6 @@
"use client";
import Image from "next/image";
import { memo, useCallback, useMemo } from "react";
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import {
Select,
SelectContent,
@@ -10,89 +8,54 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
BLUR_INTENSITY_PRESETS,
DEFAULT_BLUR_INTENSITY,
DEFAULT_COLOR,
FPS_PRESETS,
} from "@/constants/project-constants";
import { patternCraftGradients } from "@/data/colors/pattern-craft";
import { colors } from "@/data/colors/solid";
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
import { FPS_PRESETS } from "@/constants/project-constants";
import { useEditor } from "@/hooks/use-editor";
import { useEditorStore } from "@/stores/editor-store";
import type { TProject } from "@/types/project";
import { dimensionToAspectRatio } from "@/utils/geometry";
import { cn } from "@/utils/ui";
import {
PropertyGroup,
PropertyItem,
PropertyItemLabel,
PropertyItemValue,
} from "@/components/editor/panels/properties/property-item";
import { HugeiconsIcon } from "@hugeicons/react";
import { DropperIcon } from "@hugeicons/core-free-icons";
Section,
SectionContent,
SectionHeader,
} from "@/components/editor/panels/properties/section";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useState } from "react";
export function SettingsView() {
return <ProjectSettingsTabs />;
}
const [open, setOpen] = useState(false);
function ProjectSettingsTabs() {
return (
<BaseView
defaultTab="project-info"
tabs={[
{
value: "project-info",
label: "Project info",
content: (
<div className="p-5">
<ProjectInfoView />
</div>
),
},
{
value: "background",
label: "Background",
content: (
<div className="flex h-full flex-col justify-between">
<div className="flex-1 p-5">
<BackgroundView />
<PanelView contentClassName="px-0" hideHeader>
<div className="flex flex-col">
<Section hasBorderTop={false}>
<SectionContent>
<ProjectInfoContent />
</SectionContent>
</Section>
<Popover open={open} onOpenChange={setOpen}>
<Section className="cursor-pointer">
<PopoverTrigger asChild>
<div>
<SectionHeader title="Background">
<div className="size-4 rounded-sm bg-red-500" />
</SectionHeader>
</div>
{/* <div className="bg-panel/85 sticky -bottom-0 flex flex-col backdrop-blur-lg">
<Separator />
<Button className="text-muted-foreground hover:text-foreground/85 h-auto w-fit !bg-transparent p-5 py-4 text-xs shadow-none">
Custom background
<PlusIcon />
</Button>
</div> */}
{/* another ui */}
{/* <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>
<PlusIcon className="" />
</Button>
</div> */}
</div>
),
},
]}
className="flex h-full flex-col justify-between p-0"
/>
</PopoverTrigger>
</Section>
<PopoverContent>
<div className="size-4 rounded-sm bg-red-500" />
</PopoverContent>
</Popover>
</div>
</PanelView>
);
}
function getCurrentCanvasSize({ activeProject }: { activeProject: TProject }) {
const { canvasSize } = activeProject.settings;
return {
width: canvasSize.width,
height: canvasSize.height,
};
}
function ProjectInfoView() {
function ProjectInfoContent() {
const editor = useEditor();
const activeProject = editor.project.getActive();
const { canvasPresets } = useEditorStore();
@@ -117,7 +80,7 @@ function ProjectInfoView() {
return -1;
};
const currentCanvasSize = getCurrentCanvasSize({ activeProject });
const currentCanvasSize = activeProject.settings.canvasSize;
const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
const presetIndex = findPresetIndexByAspectRatio({
@@ -150,245 +113,55 @@ function ProjectInfoView() {
return (
<div className="flex flex-col gap-4">
<PropertyItem direction="column">
<PropertyItemLabel>Name</PropertyItemLabel>
<PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Aspect ratio</PropertyItemLabel>
<PropertyItemValue>
<Select
value={selectedPresetValue}
onValueChange={(value) => handleAspectRatioChange({ value })}
>
<SelectTrigger className="bg-panel-accent">
<SelectValue placeholder="Select an aspect ratio" />
</SelectTrigger>
<SelectContent>
<SelectItem value={originalPresetValue}>Original</SelectItem>
{canvasPresets.map((preset, index) => {
const label = dimensionToAspectRatio({
width: preset.width,
height: preset.height,
});
return (
<SelectItem key={label} value={index.toString()}>
{label}
</SelectItem>
);
})}
</SelectContent>
</Select>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Frame rate</PropertyItemLabel>
<PropertyItemValue>
<Select
value={activeProject.settings.fps.toString()}
onValueChange={handleFpsChange}
>
<SelectTrigger className="bg-panel-accent">
<SelectValue placeholder="Select a frame rate" />
</SelectTrigger>
<SelectContent>
{FPS_PRESETS.map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
</PropertyItemValue>
</PropertyItem>
</div>
);
}
const BlurPreview = memo(
({
blur,
isSelected,
onSelect,
}: {
blur: { label: string; value: number };
isSelected: boolean;
onSelect: () => void;
}) => (
<button
className={cn(
"border-foreground/15 hover:border-primary relative aspect-square w-full cursor-pointer overflow-hidden rounded-sm border",
isSelected && "border-primary border-2",
)}
onClick={onSelect}
type="button"
aria-label={`Select ${blur.label} blur`}
>
<Image
src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
alt={`Blur preview ${blur.label}`}
fill
className="object-cover"
style={{ filter: `blur(${blur.value}px)` }}
loading="eager"
/>
<div className="absolute right-1 bottom-1 left-1 text-center">
<span className="rounded bg-black/50 px-1 text-xs text-white">
{blur.label}
<div className="flex flex-col gap-2">
<Label>Name</Label>
<span className="leading-none text-sm">
{activeProject.metadata.name}
</span>
</div>
</button>
),
);
BlurPreview.displayName = "BlurPreview";
const BackgroundPreviews = memo(
({
backgrounds,
currentBackgroundColor,
isColorBackground,
handleColorSelect,
useBackgroundColor = false,
}: {
backgrounds: string[];
currentBackgroundColor: string;
isColorBackground: boolean;
handleColorSelect: ({ bg }: { bg: string }) => void;
useBackgroundColor?: boolean;
}) => {
return useMemo(
() =>
backgrounds.map((bg, index) => (
<button
key={`${index}-${bg}`}
className={cn(
"border-foreground/15 hover:border-primary aspect-square w-full cursor-pointer rounded-sm border",
isColorBackground &&
bg === currentBackgroundColor &&
"border-primary border-2",
)}
style={
useBackgroundColor
? { backgroundColor: bg }
: {
background: bg,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}
}
onClick={() => handleColorSelect({ bg })}
type="button"
aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`}
/>
)),
[
backgrounds,
isColorBackground,
currentBackgroundColor,
handleColorSelect,
useBackgroundColor,
],
);
},
);
BackgroundPreviews.displayName = "BackgroundPreviews";
function BackgroundView() {
const editor = useEditor();
const activeProject = editor.project.getActive();
const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
const handleBlurSelect = useCallback(
async ({ blurIntensity }: { blurIntensity: number }) => {
await editor.project.updateSettings({
settings: { background: { type: "blur", blurIntensity } },
});
},
[editor.project],
);
const handleColorSelect = useCallback(
async ({ color }: { color: string }) => {
await editor.project.updateSettings({
settings: { background: { type: "color", color } },
});
},
[editor.project],
);
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(
() =>
blurLevels.map((blur) => (
<BlurPreview
key={blur.value}
blur={blur}
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
onSelect={() => handleBlurSelect({ blurIntensity: blur.value })}
/>
)),
[blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
);
return (
<div className="flex h-full flex-col gap-4">
<PropertyGroup title="Blur" defaultExpanded={false}>
<div className="grid w-full grid-cols-4 gap-2">{blurPreviews}</div>
</PropertyGroup>
<PropertyGroup title="Colors" defaultExpanded={false}>
<div className="grid w-full grid-cols-4 gap-2">
<div className="border-foreground/15 hover:border-primary flex aspect-square w-full cursor-pointer items-center justify-center rounded-sm border">
<HugeiconsIcon icon={DropperIcon} className="size-4" />
</div>
<BackgroundPreviews
backgrounds={colors}
currentBackgroundColor={currentBackgroundColor}
isColorBackground={isColorBackground}
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
useBackgroundColor={true}
/>
</div>
</PropertyGroup>
<PropertyGroup title="Pattern craft" defaultExpanded={false}>
<div className="grid w-full grid-cols-4 gap-2">
<BackgroundPreviews
backgrounds={patternCraftGradients}
currentBackgroundColor={currentBackgroundColor}
isColorBackground={isColorBackground}
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
/>
</div>
</PropertyGroup>
<PropertyGroup title="Syntax UI" defaultExpanded={false}>
<div className="grid w-full grid-cols-4 gap-2">
<BackgroundPreviews
backgrounds={syntaxUIGradients}
currentBackgroundColor={currentBackgroundColor}
isColorBackground={isColorBackground}
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
/>
</div>
</PropertyGroup>
<div className="flex flex-col gap-2">
<Label>Aspect ratio</Label>
<Select
value={selectedPresetValue}
onValueChange={(value) => handleAspectRatioChange({ value })}
>
<SelectTrigger className="w-fit">
<SelectValue placeholder="Select an aspect ratio" />
</SelectTrigger>
<SelectContent>
<SelectItem value={originalPresetValue}>Original</SelectItem>
{canvasPresets.map((preset, index) => {
const label = dimensionToAspectRatio({
width: preset.width,
height: preset.height,
});
return (
<SelectItem key={label} value={index.toString()}>
{label}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Frame rate</Label>
<Select
value={activeProject.settings.fps.toString()}
onValueChange={handleFpsChange}
>
<SelectTrigger className="w-fit">
<SelectValue placeholder="Select a frame rate" />
</SelectTrigger>
<SelectContent>
{FPS_PRESETS.map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
@@ -235,7 +235,7 @@ function SoundEffectsView() {
<div className="flex items-center gap-3">
<Input
placeholder="Search sound effects"
className="bg-panel-accent w-full"
className="w-full"
containerClassName="w-full"
value={searchQuery}
onChange={({ currentTarget }) =>
@@ -406,7 +406,7 @@ function SavedSoundsView() {
if (savedSounds.length === 0) {
return (
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
<div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
<HugeiconsIcon
icon={FavouriteIcon}
className="text-muted-foreground size-10"
@@ -4,144 +4,104 @@ import Image from "next/image";
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
import { Button } from "@/components/ui/button";
import { InputWithBack } from "@/components/ui/input-with-back";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { STICKER_CATEGORIES } from "@/constants/stickers-constants";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import {
buildIconSvgUrl,
getIconSvgUrl,
ICONIFY_HOSTS,
POPULAR_COLLECTIONS,
} from "@/lib/iconify-api";
resolveStickerId,
type StickerItem as StickerData,
} from "@/lib/stickers";
import { useStickersStore } from "@/stores/stickers-store";
import type { StickerCategory } from "@/types/stickers";
import { cn } from "@/utils/ui";
import {
ArrowRightIcon,
HappyIcon,
ClockIcon,
LayoutGridIcon,
MultiplicationSignIcon,
SparklesIcon,
HashtagIcon,
Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Spinner } from "@/components/ui/spinner";
function isStickerCategory(value: string): value is StickerCategory {
return STICKER_CATEGORIES.includes(value as StickerCategory);
}
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { OcSlidersVerticalIcon } from "@opencut/ui/icons";
import type { StickerCategory } from "@/types/stickers";
import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
import { parseStickerId } from "@/lib/stickers/sticker-id";
export function StickersView() {
const { selectedCategory, setSelectedCategory } = useStickersStore();
return (
<BaseView
value={selectedCategory}
onValueChange={(v) => {
if (isStickerCategory(v)) {
setSelectedCategory({ category: v });
}
}}
tabs={[
{
value: "all",
label: "All",
icon: <HugeiconsIcon icon={LayoutGridIcon} className="size-3" />,
content: <StickersContentView category="all" />,
},
{
value: "general",
label: "Icons",
icon: <HugeiconsIcon icon={SparklesIcon} className="size-3" />,
content: <StickersContentView category="general" />,
},
{
value: "brands",
label: "Brands",
icon: <HugeiconsIcon icon={HashtagIcon} className="size-3" />,
content: <StickersContentView category="brands" />,
},
{
value: "emoji",
label: "Emoji",
icon: <HugeiconsIcon icon={HappyIcon} className="size-3" />,
content: <StickersContentView category="emoji" />,
},
]}
className="flex h-full flex-col overflow-hidden p-0"
/>
<PanelView
title="Stickers"
actions={
<div className="flex items-center">
<Select
value={selectedCategory}
onValueChange={(value: StickerCategory) =>
setSelectedCategory({ category: value })
}
>
<SelectTrigger variant="outline" size="sm" className="mr-1.5">
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
{Object.entries(STICKER_CATEGORIES).map(([category, label]) => (
<SelectItem key={category} value={category}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="ghost" size="icon">
<HugeiconsIcon icon={Search01Icon} className="!size-3.5" />
</Button>
<Button variant="ghost" size="icon">
<OcSlidersVerticalIcon className="!size-3.5" />
</Button>
</div>
}
>
<StickersContentView />
</PanelView>
);
}
function StickerGrid({
icons,
onAdd,
addingSticker,
capSize = false,
items,
shouldCapSize = false,
}: {
icons: string[];
onAdd: (iconName: string) => void;
addingSticker: string | null;
capSize?: boolean;
items: StickerData[];
shouldCapSize?: boolean;
}) {
const gridStyle: CSSProperties & {
"--sticker-min": string;
"--sticker-max"?: string;
} = {
gridTemplateColumns: capSize
gridTemplateColumns: shouldCapSize
? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))"
: "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))",
"--sticker-min": "96px",
...(capSize ? { "--sticker-max": "160px" } : {}),
...(shouldCapSize ? { "--sticker-max": "160px" } : {}),
};
return (
<div className="grid gap-2" style={gridStyle}>
{icons.map((iconName) => (
<StickerItem
key={iconName}
iconName={iconName}
onAdd={onAdd}
isAdding={addingSticker === iconName}
capSize={capSize}
/>
))}
</div>
);
}
function CollectionGrid({
collections,
onSelectCollection,
}: {
collections: Array<{
prefix: string;
name: string;
total: number;
category?: string;
}>;
onSelectCollection: ({ prefix }: { prefix: string }) => void;
}) {
return (
<div className="grid grid-cols-1 gap-2">
{collections.map((collection) => (
<CollectionItem
key={collection.prefix}
title={collection.name}
subtitle={`${collection.total.toLocaleString()} icons${collection.category ? ` • ${collection.category}` : ""}`}
onClick={() => onSelectCollection({ prefix: collection.prefix })}
/>
{items.map((item) => (
<StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} />
))}
</div>
);
@@ -149,7 +109,7 @@ function CollectionGrid({
function EmptyView({ message }: { message: string }) {
return (
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
<div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
<HugeiconsIcon
icon={HappyIcon}
className="text-muted-foreground size-10"
@@ -162,372 +122,125 @@ function EmptyView({ message }: { message: string }) {
);
}
function StickersContentView({ category }: { category: StickerCategory }) {
function StickersContentView() {
const {
searchQuery,
selectedCollection,
viewMode,
collections,
currentCollection,
searchResults,
recentStickers,
isLoadingCollections,
isLoadingCollection,
isSearching,
setSearchQuery,
setSelectedCollection,
loadCollections,
searchStickers,
addStickerToTimeline,
clearRecentStickers,
setSelectedCategory,
addingSticker,
} = useStickersStore();
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
const [collectionsToShow, setCollectionsToShow] = useState(20);
const [showCollectionItems, setShowCollectionItems] = useState(false);
const filteredCollections = useMemo(() => {
if (category === "all") {
return Object.entries(collections).map(([prefix, collection]) => ({
prefix,
name: collection.name,
total: collection.total,
category: collection.category,
}));
const itemsToDisplay = useMemo(() => {
if (viewMode === "search" && searchResults) {
return searchResults.items;
}
const collectionList =
POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS];
if (!collectionList) return [];
return [];
}, [viewMode, searchResults]);
return collectionList
.map((c) => {
const collection = collections[c.prefix];
return collection
? {
prefix: c.prefix,
name: c.name,
total: collection.total,
}
: null;
})
.filter(Boolean) as Array<{
prefix: string;
name: string;
total: number;
}>;
}, [collections, category]);
const { scrollAreaRef, handleScroll } = useInfiniteScroll({
onLoadMore: () => setCollectionsToShow((prev) => prev + 20),
hasMore: filteredCollections.length > collectionsToShow,
isLoading: isLoadingCollections,
enabled: viewMode === "browse" && !selectedCollection && category === "all",
});
useEffect(() => {
if (Object.keys(collections).length === 0) {
loadCollections();
}
}, [collections, loadCollections]);
useEffect(() => {
const timer = setTimeout(() => {
if (localSearchQuery !== searchQuery) {
setSearchQuery({ query: localSearchQuery });
if (localSearchQuery.trim()) {
searchStickers({ query: localSearchQuery });
}
const recentStickerItems = useMemo(() => {
const items: StickerData[] = [];
for (const stickerId of recentStickers) {
const recentStickerItem = toRecentStickerItem({ stickerId });
if (recentStickerItem) {
items.push(recentStickerItem);
}
}, 500);
}
return items;
}, [recentStickers]);
return () => clearTimeout(timer);
}, [localSearchQuery, searchQuery, searchStickers, setSearchQuery]);
return (
<div className="flex h-full flex-col gap-4">
{recentStickerItems.length > 0 && viewMode === "browse" && (
<div className="flex h-full flex-col gap-2">
<div className="flex items-center gap-2">
<HugeiconsIcon
icon={ClockIcon}
className="text-muted-foreground size-4"
/>
<span className="text-sm font-medium">Recent</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={clearRecentStickers}
className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0"
>
<HugeiconsIcon
icon={MultiplicationSignIcon}
className="text-muted-foreground size-3"
/>
</button>
</TooltipTrigger>
<TooltipContent>
<p>Clear recent stickers</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<StickerGrid items={recentStickerItems.slice(0, 12)} shouldCapSize />
</div>
)}
const handleAddSticker = async (iconName: string) => {
{viewMode === "search" && (
<div className="h-full">
{isSearching ? (
<div className="flex items-center justify-center py-8">
<Spinner className="text-muted-foreground size-6" />
</div>
) : searchResults?.items.length ? (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">
{searchResults.total} results
</span>
</div>
<StickerGrid items={itemsToDisplay} shouldCapSize />
</div>
) : searchQuery ? (
<EmptyView message={`No stickers found for "${searchQuery}"`} />
) : null}
</div>
)}
</div>
);
}
interface StickerItemProps {
item: StickerData;
shouldCapSize?: boolean;
}
function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
const { addingSticker, addStickerToTimeline } = useStickersStore();
const isAdding = addingSticker === item.id;
const [hasImageError, setHasImageError] = useState(false);
useEffect(() => {
if (!item.id) {
return;
}
setHasImageError(false);
}, [item.id]);
const displayName = item.name;
const handleAdd = async () => {
try {
await addStickerToTimeline({ iconName });
await addStickerToTimeline({
stickerId: item.id,
name: item.name,
});
} catch (error) {
console.error("Failed to add sticker:", error);
toast.error("Failed to add sticker to timeline");
}
};
const iconsToDisplay = useMemo(() => {
if (viewMode === "search" && searchResults) {
return searchResults.icons;
}
if (viewMode === "collection" && currentCollection) {
const icons: string[] = [];
if (currentCollection.uncategorized) {
icons.push(
...currentCollection.uncategorized.map(
(name) => `${currentCollection.prefix}:${name}`,
),
);
}
if (currentCollection.categories) {
Object.values(currentCollection.categories).forEach((categoryIcons) => {
icons.push(
...categoryIcons.map(
(name) => `${currentCollection.prefix}:${name}`,
),
);
});
}
return icons.slice(0, 100);
}
return [];
}, [viewMode, searchResults, currentCollection]);
const isInCollection = viewMode === "collection" && !!selectedCollection;
useEffect(() => {
if (isInCollection) {
setShowCollectionItems(false);
const timer = setTimeout(() => setShowCollectionItems(true), 350);
return () => clearTimeout(timer);
} else {
setShowCollectionItems(false);
}
}, [isInCollection]);
return (
<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({ collection: null });
}
}}
placeholder={
category === "all"
? "Search all stickers"
: category === "general"
? "Search icons"
: category === "brands"
? "Search brands"
: "Search Emojis"
}
value={localSearchQuery}
onChange={setLocalSearchQuery}
disableAnimation={true}
/>
</div>
<div className="relative h-full overflow-hidden">
<ScrollArea
className="h-full flex-1"
ref={scrollAreaRef}
onScrollCapture={handleScroll}
>
<div className="flex h-full flex-col gap-4">
{recentStickers.length > 0 && viewMode === "browse" && (
<div className="h-full">
<div className="mb-2 flex items-center gap-2">
<HugeiconsIcon
icon={ClockIcon}
className="text-muted-foreground size-4"
/>
<span className="text-sm font-medium">Recent</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={clearRecentStickers}
className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0"
>
<HugeiconsIcon
icon={MultiplicationSignIcon}
className="text-muted-foreground size-3"
/>
</button>
</TooltipTrigger>
<TooltipContent>
<p>Clear recent stickers</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<StickerGrid
icons={recentStickers.slice(0, 12)}
onAdd={handleAddSticker}
addingSticker={addingSticker}
capSize
/>
</div>
)}
{viewMode === "collection" && selectedCollection && (
<div className="h-full">
{isLoadingCollection ? (
<div className="flex items-center justify-center py-8">
<Spinner className="text-muted-foreground size-6" />
</div>
) : showCollectionItems ? (
<StickerGrid
icons={iconsToDisplay}
onAdd={handleAddSticker}
addingSticker={addingSticker}
/>
) : (
<div className="flex items-center justify-center py-8">
<Spinner className="text-muted-foreground size-6" />
</div>
)}
</div>
)}
{viewMode === "search" && (
<div className="h-full">
{isSearching ? (
<div className="flex items-center justify-center py-8">
<Spinner className="text-muted-foreground size-6" />
</div>
) : searchResults?.icons.length ? (
<>
<div className="mb-3 flex items-center justify-between">
<span className="text-muted-foreground text-sm">
{searchResults.total} results
</span>
</div>
<StickerGrid
icons={iconsToDisplay}
onAdd={handleAddSticker}
addingSticker={addingSticker}
capSize
/>
</>
) : searchQuery ? (
<div className="flex flex-col items-center justify-center gap-3 py-8">
<EmptyView
message={`No stickers found for "${searchQuery}"`}
/>
{category !== "all" && (
<Button
variant="outline"
onClick={() => {
const q = localSearchQuery || searchQuery;
if (q) {
setSearchQuery({ query: q });
}
setSelectedCategory({ category: "all" });
if (q) {
searchStickers({ query: q });
}
}}
>
Search in all icons
</Button>
)}
</div>
) : null}
</div>
)}
{viewMode === "browse" && !selectedCollection && (
<div className="h-full space-y-4">
{isLoadingCollections ? (
<div className="flex items-center justify-center py-8">
<Spinner className="text-muted-foreground size-6" />
</div>
) : (
<>
{category !== "all" && (
<div className="h-full">
<CollectionGrid
collections={filteredCollections}
onSelectCollection={({ prefix }) =>
setSelectedCollection({ collection: prefix })
}
/>
</div>
)}
{category === "all" && filteredCollections.length > 0 && (
<div className="h-full">
<CollectionGrid
collections={filteredCollections.slice(
0,
collectionsToShow,
)}
onSelectCollection={({ prefix }) =>
setSelectedCollection({ collection: prefix })
}
/>
</div>
)}
</>
)}
</div>
)}
</div>
</ScrollArea>
</div>
</div>
);
}
interface CollectionItemProps {
title: string;
subtitle: string;
onClick: () => void;
}
function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) {
return (
<Button
variant="outline"
className="h-auto justify-between rounded-md py-2"
onClick={onClick}
>
<div className="text-left">
<p className="font-medium">{title}</p>
<p className="text-muted-foreground text-xs">{subtitle}</p>
</div>
<HugeiconsIcon icon={ArrowRightIcon} className="size-4" />
</Button>
);
}
interface StickerItemProps {
iconName: string;
onAdd: (iconName: string) => void;
isAdding?: boolean;
capSize?: boolean;
}
function StickerItem({
iconName,
onAdd,
isAdding,
capSize = false,
}: StickerItemProps) {
const [imageError, setImageError] = useState(false);
const [hostIndex, setHostIndex] = useState(0);
useEffect(() => {
if (!iconName) {
return;
}
setImageError(false);
setHostIndex(0);
}, [iconName]);
const displayName = iconName.split(":")[1] || iconName;
const collectionPrefix = iconName.split(":")[0];
const preview = imageError ? (
const preview = hasImageError ? (
<div className="flex size-full items-center justify-center p-2">
<span className="text-muted-foreground text-center text-xs break-all">
{displayName}
@@ -536,21 +249,13 @@ function StickerItem({
) : (
<div className="flex size-full items-center justify-center p-4">
<Image
src={
hostIndex === 0
? getIconSvgUrl(iconName, { width: 64, height: 64 })
: buildIconSvgUrl(
ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)],
iconName,
{ width: 64, height: 64 },
)
}
src={item.previewUrl}
alt={displayName}
width={64}
height={64}
className="size-full object-contain"
style={
capSize
shouldCapSize
? {
maxWidth: "var(--sticker-max, 160px)",
maxHeight: "var(--sticker-max, 160px)",
@@ -558,12 +263,7 @@ function StickerItem({
: undefined
}
onError={() => {
const next = hostIndex + 1;
if (next < ICONIFY_HOSTS.length) {
setHostIndex(next);
} else {
setImageError(true);
}
setHasImageError(true);
}}
loading="lazy"
unoptimized
@@ -572,44 +272,63 @@ function StickerItem({
);
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"relative",
isAdding && "pointer-events-none opacity-50",
)}
>
<DraggableItem
name={displayName}
preview={preview}
dragData={{
id: iconName,
type: "sticker",
name: displayName,
iconName,
}}
onAddToTimeline={() => onAdd(iconName)}
aspectRatio={1}
shouldShowLabel={false}
isRounded={true}
variant="card"
className=""
containerClassName="w-full"
/>
{isAdding && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
<Spinner className="size-6 text-white" />
</div>
)}
<div
className={cn("relative", isAdding && "pointer-events-none opacity-50")}
>
<DraggableItem
name={displayName}
preview={preview}
dragData={{
id: item.id,
type: "sticker",
name: displayName,
stickerId: item.id,
}}
onAddToTimeline={handleAdd}
aspectRatio={1}
shouldShowLabel={false}
isRounded
variant="card"
containerClassName="w-full"
/>
{isAdding && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
<Spinner className="size-6 text-white" />
</div>
</TooltipTrigger>
<TooltipContent>
<div className="space-y-1">
<p className="font-medium">{displayName}</p>
<p className="text-muted-foreground text-xs">{collectionPrefix}</p>
</div>
</TooltipContent>
</Tooltip>
)}
</div>
);
}
function getStickerNameFromId({ stickerId }: { stickerId: string }): string {
const stickerIdParts = stickerId.split(":");
if (stickerIdParts.length <= 1) {
return stickerId;
}
return (
stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ??
stickerId
);
}
function toRecentStickerItem({
stickerId,
}: {
stickerId: string;
}): StickerData | null {
try {
const { providerId } = parseStickerId({ stickerId });
return {
id: stickerId,
provider: providerId,
name: getStickerNameFromId({ stickerId }),
previewUrl: resolveStickerId({
stickerId,
options: { width: 64, height: 64 },
}),
metadata: {},
};
} catch {
return null;
}
}
@@ -1,5 +1,5 @@
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { buildTextElement } from "@/lib/timeline/element-utils";
@@ -23,11 +23,11 @@ export function TextView() {
};
return (
<BaseView>
<PanelView title="Text">
<DraggableItem
name="Default text"
preview={
<div className="bg-panel-accent flex size-full items-center justify-center rounded">
<div className="bg-accent flex size-full items-center justify-center rounded">
<span className="text-xs select-none">Default text</span>
</div>
}
@@ -41,6 +41,6 @@ export function TextView() {
onAddToTimeline={handleAddToTimeline}
shouldShowLabel={false}
/>
</BaseView>
</PanelView>
);
}
@@ -1,85 +0,0 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/utils/ui";
interface PanelBaseViewProps {
children?: React.ReactNode;
defaultTab?: string;
value?: string;
onValueChange?: (value: string) => void;
tabs?: {
value: string;
label: string;
icon?: React.ReactNode;
content: React.ReactNode;
}[];
className?: string;
ref?: React.Ref<HTMLDivElement>;
}
function ViewContent({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<ScrollArea className="flex-1">
<div className={cn("p-5", className)}>{children}</div>
</ScrollArea>
);
}
export function PanelBaseView({
children,
defaultTab,
value,
onValueChange,
tabs,
className = "",
ref,
}: PanelBaseViewProps) {
return (
<div className={cn("flex h-full flex-col", className)} ref={ref}>
{!tabs || tabs.length === 0 ? (
<ViewContent className={className}>{children}</ViewContent>
) : (
<Tabs
defaultValue={defaultTab}
value={value}
onValueChange={onValueChange}
className="flex h-full flex-col"
>
<div className="bg-panel sticky top-0 z-10">
<div className="px-3 pt-3 pb-0">
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.icon ? (
<span className="mr-1 inline-flex items-center">
{tab.icon}
</span>
) : null}
{tab.label}
</TabsTrigger>
))}
</TabsList>
</div>
<Separator className="mt-3" />
</div>
{tabs.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
className="mt-0 flex min-h-0 flex-1 flex-col"
>
<ViewContent className={className}>{tab.content}</ViewContent>
</TabsContent>
))}
</Tabs>
)}
</div>
);
}
@@ -0,0 +1,59 @@
"use client";
import { useEditor } from "@/hooks/use-editor";
import { findCurrentScene } from "@/lib/scenes";
import { getBookmarksActiveAtTime } from "@/lib/timeline/bookmarks";
export function BookmarkNoteOverlay() {
const editor = useEditor();
const currentTime = editor.playback.getCurrentTime();
const activeProject = editor.project.getActive();
if (!activeProject) {
return null;
}
const activeScene = findCurrentScene({
scenes: activeProject.scenes,
currentSceneId: activeProject.currentSceneId,
});
if (!activeScene) {
return null;
}
const bookmarks = activeScene.bookmarks;
const activeBookmarks = getBookmarksActiveAtTime({
bookmarks,
time: currentTime,
});
const bookmarksWithNotes = activeBookmarks.filter(
(bookmark) => bookmark.note != null && bookmark.note.trim() !== "",
);
if (bookmarksWithNotes.length === 0) {
return null;
}
return (
<div
className="pointer-events-none absolute top-2 left-2 flex flex-col gap-1.5"
aria-live="polite"
>
{bookmarksWithNotes.map((bookmark) => (
<div
key={bookmark.time}
className="flex max-w-[min(200px,50vw)] px-2.5 py-1.5 text-left text-white text-xs shadow-md backdrop-blur-sm"
style={{
backgroundColor: "rgb(0 0 0 / 0.5)",
borderLeft: bookmark.color
? `3px solid ${bookmark.color}`
: "3px solid var(--primary)",
}}
>
{bookmark.note}
</div>
))}
</div>
);
}
@@ -0,0 +1,35 @@
"use client";
import {
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
} from "@/components/ui/context-menu";
import { usePreviewStore } from "@/stores/preview-store";
export function PreviewContextMenu({
onToggleFullscreen,
containerRef,
}: {
onToggleFullscreen: () => void;
containerRef: React.RefObject<HTMLElement | null>;
}) {
const { overlays, setOverlayVisibility } = usePreviewStore();
return (
<ContextMenuContent className="w-56" container={containerRef.current}>
<ContextMenuItem onClick={onToggleFullscreen} inset>
Full screen
</ContextMenuItem>
<ContextMenuCheckboxItem
checked={overlays.bookmarks}
onCheckedChange={(checked) =>
setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked })
}
>
Show bookmarks
</ContextMenuCheckboxItem>
<ContextMenuItem inset>Show grid</ContextMenuItem>
</ContextMenuContent>
);
}
@@ -4,10 +4,18 @@ import { useCallback, useMemo, useRef } from "react";
import useDeepCompareEffect from "use-deep-compare-effect";
import { useEditor } from "@/hooks/use-editor";
import { useRafLoop } from "@/hooks/use-raf-loop";
import { useContainerSize } from "@/hooks/use-container-size";
import { useFullscreen } from "@/hooks/use-fullscreen";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import type { RootNode } from "@/services/renderer/nodes/root-node";
import { buildScene } from "@/services/renderer/scene-builder";
import { getLastFrameTime } from "@/lib/time";
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
import { BookmarkNoteOverlay } from "./bookmark-note-overlay";
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import { usePreviewStore } from "@/stores/preview-store";
import { PreviewContextMenu } from "./context-menu";
import { PreviewToolbar } from "./toolbar";
function usePreviewSize() {
const editor = useEditor();
@@ -19,6 +27,30 @@ function usePreviewSize() {
};
}
export function PreviewPanel() {
const containerRef = useRef<HTMLDivElement>(null);
const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef });
return (
<div
ref={containerRef}
className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border"
>
<div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0">
<PreviewCanvas
onToggleFullscreen={toggleFullscreen}
containerRef={containerRef}
/>
<RenderTreeController />
</div>
<PreviewToolbar
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
/>
</div>
);
}
function RenderTreeController() {
const editor = useEditor();
const tracks = editor.timeline.getTracks();
@@ -45,38 +77,66 @@ function RenderTreeController() {
return null;
}
export function PreviewPanel() {
return (
<div className="bg-panel relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm">
<div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2">
<PreviewCanvas />
<RenderTreeController />
</div>
</div>
);
}
function PreviewCanvas() {
const ref = useRef<HTMLCanvasElement>(null);
function PreviewCanvas({
onToggleFullscreen,
containerRef,
}: {
onToggleFullscreen: () => void;
containerRef: React.RefObject<HTMLElement | null>;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const outerContainerRef = useRef<HTMLDivElement>(null);
const canvasBoundsRef = useRef<HTMLDivElement>(null);
const lastFrameRef = useRef(-1);
const lastSceneRef = useRef<RootNode | null>(null);
const renderingRef = useRef(false);
const { width, height } = usePreviewSize();
const { width: nativeWidth, height: nativeHeight } = usePreviewSize();
const containerSize = useContainerSize({ containerRef: outerContainerRef });
const editor = useEditor();
const activeProject = editor.project.getActive();
const { overlays } = usePreviewStore();
const renderer = useMemo(() => {
return new CanvasRenderer({
width,
height,
width: nativeWidth,
height: nativeHeight,
fps: activeProject.settings.fps,
});
}, [width, height, activeProject.settings.fps]);
}, [nativeWidth, nativeHeight, activeProject.settings.fps]);
const displaySize = useMemo(() => {
if (
!nativeWidth ||
!nativeHeight ||
containerSize.width === 0 ||
containerSize.height === 0
) {
return { width: nativeWidth ?? 0, height: nativeHeight ?? 0 };
}
const paddingBuffer = 4;
const availableWidth = containerSize.width - paddingBuffer;
const availableHeight = containerSize.height - paddingBuffer;
const aspectRatio = nativeWidth / nativeHeight;
const containerAspect = availableWidth / availableHeight;
const displayWidth =
containerAspect > aspectRatio
? availableHeight * aspectRatio
: availableWidth;
const displayHeight =
containerAspect > aspectRatio
? availableHeight
: availableWidth / aspectRatio;
return { width: displayWidth, height: displayHeight };
}, [nativeWidth, nativeHeight, containerSize.width, containerSize.height]);
const renderTree = editor.renderer.getRenderTree();
const render = useCallback(() => {
if (ref.current && renderTree && !renderingRef.current) {
if (canvasRef.current && renderTree && !renderingRef.current) {
const time = editor.playback.getCurrentTime();
const lastFrameTime = getLastFrameTime({
duration: renderTree.duration,
@@ -96,7 +156,7 @@ function PreviewCanvas() {
.renderToCanvas({
node: renderTree,
time: renderTime,
targetCanvas: ref.current,
targetCanvas: canvasRef.current,
})
.then(() => {
renderingRef.current = false;
@@ -108,17 +168,43 @@ function PreviewCanvas() {
useRafLoop(render);
return (
<canvas
ref={ref}
width={width}
height={height}
className="block max-h-full max-w-full border"
style={{
background:
activeProject.settings.background.type === "blur"
? "transparent"
: activeProject?.settings.background.color,
}}
/>
<div
ref={outerContainerRef}
className="relative flex size-full items-center justify-center"
>
<ContextMenu>
<ContextMenuTrigger asChild>
<div
ref={canvasBoundsRef}
className="relative"
style={{ width: displaySize.width, height: displaySize.height }}
>
<canvas
ref={canvasRef}
width={nativeWidth}
height={nativeHeight}
className="block border"
style={{
width: displaySize.width,
height: displaySize.height,
background:
activeProject.settings.background.type === "blur"
? "transparent"
: activeProject?.settings.background.color,
}}
/>
<PreviewInteractionOverlay
canvasRef={canvasRef}
containerRef={canvasBoundsRef}
/>
{overlays.bookmarks && <BookmarkNoteOverlay />}
</div>
</ContextMenuTrigger>
<PreviewContextMenu
onToggleFullscreen={onToggleFullscreen}
containerRef={containerRef}
/>
</ContextMenu>
</div>
);
}
@@ -1,27 +1,27 @@
"use client";
import { useEditorStore } from "@/stores/editor-store";
import Image from "next/image";
function TikTokGuide() {
return (
<div className="pointer-events-none absolute inset-0">
<Image
src="/platform-guides/tiktok-blueprint.png"
alt="TikTok layout guide"
className="absolute inset-0 size-full object-contain"
draggable={false}
fill
/>
</div>
);
}
export function LayoutGuideOverlay() {
const { layoutGuide } = useEditorStore();
if (layoutGuide.platform === null) return null;
if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
return null;
}
"use client";
import { usePreviewStore } from "@/stores/preview-store";
import Image from "next/image";
function TikTokGuide() {
return (
<div className="pointer-events-none absolute inset-0">
<Image
src="/platform-guides/tiktok-blueprint.png"
alt="TikTok layout guide"
className="absolute inset-0 size-full object-contain"
draggable={false}
fill
/>
</div>
);
}
export function LayoutGuideOverlay() {
const { layoutGuide } = usePreviewStore();
if (layoutGuide.platform === null) return null;
if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
return null;
}
@@ -0,0 +1,54 @@
import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
import { TransformHandles } from "./transform-handles";
import { SnapGuides } from "./snap-guides";
import { TextEditOverlay } from "./text-edit-overlay";
export function PreviewInteractionOverlay({
canvasRef,
containerRef,
}: {
canvasRef: React.RefObject<HTMLCanvasElement | null>;
containerRef: React.RefObject<HTMLDivElement | null>;
}) {
const {
onPointerDown,
onPointerMove,
onPointerUp,
onDoubleClick,
snapLines,
editingText,
commitTextEdit,
cancelTextEdit,
} = usePreviewInteraction({ canvasRef });
return (
<div className="absolute inset-0">
{/* biome-ignore lint/a11y/noStaticElementInteractions: canvas overlay, pointer-only interaction */}
<div
className="absolute inset-0 pointer-events-auto"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onDoubleClick={onDoubleClick}
/>
{editingText ? (
<TextEditOverlay
canvasRef={canvasRef}
containerRef={containerRef}
trackId={editingText.trackId}
elementId={editingText.elementId}
element={editingText.element}
onCommit={commitTextEdit}
onCancel={cancelTextEdit}
/>
) : (
<TransformHandles canvasRef={canvasRef} containerRef={containerRef} />
)}
<SnapGuides
lines={snapLines}
canvasRef={canvasRef}
containerRef={containerRef}
/>
</div>
);
}
@@ -0,0 +1,65 @@
"use client";
import { useEditor } from "@/hooks/use-editor";
import type { SnapLine } from "@/lib/preview/preview-snap";
import { positionToOverlay } from "@/lib/preview/preview-coords";
export function SnapGuides({
lines,
canvasRef,
containerRef,
}: {
lines: SnapLine[];
canvasRef: React.RefObject<HTMLCanvasElement | null>;
containerRef: React.RefObject<HTMLDivElement | null>;
}) {
const editor = useEditor();
const canvasSize = editor.project.getActive().settings.canvasSize;
const canvasRect = canvasRef.current?.getBoundingClientRect();
const containerRect = containerRef.current?.getBoundingClientRect();
if (!canvasRect || !containerRect || lines.length === 0) {
return null;
}
const toOverlayX = (logicalX: number) =>
positionToOverlay({
positionX: logicalX,
positionY: 0,
canvasRect,
containerRect,
canvasSize,
}).x;
const toOverlayY = (logicalY: number) =>
positionToOverlay({
positionX: 0,
positionY: logicalY,
canvasRect,
containerRect,
canvasSize,
}).y;
return (
<div className="pointer-events-none absolute inset-0" aria-hidden>
{lines.map((line) => {
if (line.type === "vertical") {
return (
<div
key={`vertical-${line.position}`}
className="absolute top-0 bottom-0 w-px bg-white/70"
style={{ left: toOverlayX(line.position) }}
/>
);
}
return (
<div
key={`horizontal-${line.position}`}
className="absolute left-0 right-0 h-px bg-white/70"
style={{ top: toOverlayY(line.position) }}
/>
);
})}
</div>
);
}
@@ -0,0 +1,149 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useEditor } from "@/hooks/use-editor";
import type { TextElement } from "@/types/timeline";
import {
positionToOverlay,
getDisplayScale,
} from "@/lib/preview/preview-coords";
import {
DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants";
const TEXT_BACKGROUND_PADDING = "4px 8px";
const TEXT_EDIT_VERTICAL_OFFSET_EM = 0.06;
export function TextEditOverlay({
canvasRef,
containerRef,
trackId,
elementId,
element,
onCommit,
onCancel,
}: {
canvasRef: React.RefObject<HTMLCanvasElement | null>;
containerRef: React.RefObject<HTMLDivElement | null>;
trackId: string;
elementId: string;
element: TextElement;
onCommit: () => void;
onCancel: () => void;
}) {
const editor = useEditor();
const divRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const div = divRef.current;
if (!div) return;
div.focus();
const range = document.createRange();
range.selectNodeContents(div);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}, []);
const handleInput = useCallback(() => {
const div = divRef.current;
if (!div) return;
const text = div.innerText;
editor.timeline.previewElements({
updates: [{ trackId, elementId, updates: { content: text } }],
});
}, [editor.timeline, trackId, elementId]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
const { key } = event;
if (key === "Escape") {
event.preventDefault();
onCancel();
return;
}
},
[onCancel],
);
const canvasRect = canvasRef.current?.getBoundingClientRect();
const containerRect = containerRef.current?.getBoundingClientRect();
const canvasSize = editor.project.getActive().settings.canvasSize;
if (!canvasRect || !containerRect || !canvasSize) return null;
const { x: posX, y: posY } = positionToOverlay({
positionX: element.transform.position.x,
positionY: element.transform.position.y,
canvasRect,
containerRect,
canvasSize,
});
const { x: displayScaleX } = getDisplayScale({
canvasRect,
canvasSize,
});
const displayFontSize =
element.fontSize *
(canvasSize.height / FONT_SIZE_SCALE_REFERENCE) *
displayScaleX;
const verticalAlignmentOffset =
displayFontSize * TEXT_EDIT_VERTICAL_OFFSET_EM;
const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT;
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
const letterSpacing = element.letterSpacing ?? 0;
const backgroundColor =
element.backgroundColor === "transparent"
? "transparent"
: element.backgroundColor;
return (
<div
className="absolute"
style={{
left: posX,
top: posY - verticalAlignmentOffset,
transform: `translate(-50%, -50%) scale(${element.transform.scale}) rotate(${element.transform.rotate}deg)`,
transformOrigin: "center center",
}}
>
{/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
<div
ref={divRef}
contentEditable
suppressContentEditableWarning
tabIndex={0}
role="textbox"
aria-label="Edit text"
className="cursor-text select-text outline-none whitespace-pre"
style={{
fontSize: displayFontSize,
fontFamily: element.fontFamily,
fontWeight,
fontStyle,
textAlign: element.textAlign,
letterSpacing: `${letterSpacing}px`,
lineHeight,
color: element.color,
backgroundColor,
minHeight: displayFontSize * lineHeight,
textDecoration: element.textDecoration ?? "none",
padding:
backgroundColor === "transparent" ? 0 : TEXT_BACKGROUND_PADDING,
minWidth: 1,
}}
onInput={handleInput}
onBlur={onCommit}
onKeyDown={handleKeyDown}
>
{element.content || ""}
</div>
</div>
);
}
@@ -0,0 +1,80 @@
"use client";
import { useEditor } from "@/hooks/use-editor";
import { formatTimeCode } from "@/lib/time";
import { invokeAction } from "@/lib/actions";
import { EditableTimecode } from "@/components/editable-timecode";
import { Button } from "@/components/ui/button";
import {
FullScreenIcon,
PauseIcon,
PlayIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { OcSocialIcon } from "@opencut/ui/icons";
import { Separator } from "@/components/ui/separator";
export function PreviewToolbar({
isFullscreen,
onToggleFullscreen,
}: {
isFullscreen: boolean;
onToggleFullscreen: () => void;
}) {
const editor = useEditor();
const isPlaying = editor.playback.getIsPlaying();
const currentTime = editor.playback.getCurrentTime();
const totalDuration = editor.timeline.getTotalDuration();
const fps = editor.project.getActive().settings.fps;
return (
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
<div className="flex items-center">
<EditableTimecode
time={currentTime}
duration={totalDuration}
format="HH:MM:SS:FF"
fps={fps}
onTimeChange={({ time }) => editor.playback.seek({ time })}
className="text-center"
/>
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
<span className="text-muted-foreground font-mono text-xs">
{formatTimeCode({
timeInSeconds: totalDuration,
format: "HH:MM:SS:FF",
fps,
})}
</span>
</div>
<Button
variant="text"
size="icon"
onClick={() => invokeAction("toggle-play")}
>
<HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
</Button>
<div className="justify-self-end flex items-center gap-2.5">
<Button
variant="secondary"
size="sm"
className="[&_svg]:size-auto px-1 h-7"
onClick={onToggleFullscreen}
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
>
<OcSocialIcon size={20} />
</Button>
<Separator orientation="vertical" className="h-4" />
<Button
variant="text"
onClick={onToggleFullscreen}
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
>
<HugeiconsIcon icon={FullScreenIcon} />
</Button>
</div>
</div>
);
}
@@ -0,0 +1,283 @@
"use client";
import { useTransformHandles } from "@/hooks/use-transform-handles";
import { useEditor } from "@/hooks/use-editor";
import { isVisualElement } from "@/lib/timeline/element-utils";
import { SnapGuides } from "./snap-guides";
import { canvasToOverlay, getDisplayScale } from "@/lib/preview/preview-coords";
import type { ElementBounds } from "@/lib/preview/element-bounds";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { Rotate01Icon } from "@hugeicons/core-free-icons";
const HANDLE_SIZE = 10;
const ROTATION_HANDLE_OFFSET = 24;
const ROTATION_HANDLE_RADIUS = 10;
const CORNER_HIT_AREA_SIZE = 18;
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
const CORNERS: Corner[] = [
"top-left",
"top-right",
"bottom-left",
"bottom-right",
];
function getCornerPosition({
bounds,
corner,
}: {
bounds: ElementBounds;
corner: Corner;
}): { x: number; y: number } {
const halfW = bounds.width / 2;
const halfH = bounds.height / 2;
const angleRad = (bounds.rotation * Math.PI) / 180;
const cos = Math.cos(angleRad);
const sin = Math.sin(angleRad);
const localX =
corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
const localY =
corner === "top-left" || corner === "top-right" ? -halfH : halfH;
return {
x: bounds.cx + (localX * cos - localY * sin),
y: bounds.cy + (localX * sin + localY * cos),
};
}
function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): {
x: number;
y: number;
} {
const angleRad = (bounds.rotation * Math.PI) / 180;
const cos = Math.cos(angleRad);
const sin = Math.sin(angleRad);
const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET;
return {
x: bounds.cx - localY * sin,
y: bounds.cy + localY * cos,
};
}
function getOverlayContext({
canvasRef,
containerRef,
}: {
canvasRef: React.RefObject<HTMLCanvasElement | null>;
containerRef: React.RefObject<HTMLDivElement | null>;
}): {
canvasRect: DOMRect;
containerRect: DOMRect;
} | null {
const canvasRect = canvasRef.current?.getBoundingClientRect();
const containerRect = containerRef.current?.getBoundingClientRect();
if (!canvasRect || !containerRect) return null;
return { canvasRect, containerRect };
}
export function TransformHandles({
canvasRef,
containerRef,
}: {
canvasRef: React.RefObject<HTMLCanvasElement | null>;
containerRef: React.RefObject<HTMLDivElement | null>;
}) {
const {
selectedWithBounds,
hasVisualSelection,
snapLines,
handleCornerPointerDown,
handleRotationPointerDown,
handlePointerMove,
handlePointerUp,
} = useTransformHandles({ canvasRef });
const editor = useEditor();
const canvasSize = editor.project.getActive().settings.canvasSize;
if (!hasVisualSelection || !selectedWithBounds) return null;
const overlayContext = getOverlayContext({ canvasRef, containerRef });
if (!overlayContext) return null;
const { bounds, element } = selectedWithBounds;
if (!isVisualElement(element)) return null;
const { canvasRect, containerRect } = overlayContext;
const displayScale = getDisplayScale({ canvasRect, canvasSize });
const toOverlay = ({
canvasX,
canvasY,
}: {
canvasX: number;
canvasY: number;
}) =>
canvasToOverlay({
canvasX,
canvasY,
canvasRect,
containerRect,
canvasSize,
});
const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy });
const outlineWidth = bounds.width * displayScale.x;
const outlineHeight = bounds.height * displayScale.y;
const rotationHandle = getRotationHandlePosition({ bounds });
const rotationHandleScreen = toOverlay({
canvasX: rotationHandle.x,
canvasY: rotationHandle.y,
});
return (
<div className="pointer-events-none absolute inset-0" aria-hidden>
<SnapGuides
lines={snapLines}
canvasRef={canvasRef}
containerRef={containerRef}
/>
<BoundingBoxOutline
center={center}
outlineWidth={outlineWidth}
outlineHeight={outlineHeight}
rotation={bounds.rotation}
/>
{CORNERS.map((corner) => {
const cornerPosition = getCornerPosition({ bounds, corner });
const screen = toOverlay({
canvasX: cornerPosition.x,
canvasY: cornerPosition.y,
});
return (
<CornerHandle
key={corner}
corner={corner}
screen={screen}
onPointerDown={(event) =>
handleCornerPointerDown({ event, corner })
}
onPointerMove={(event) => handlePointerMove({ event })}
onPointerUp={(event) => handlePointerUp({ event })}
/>
);
})}
<RotationHandle
screen={rotationHandleScreen}
onPointerDown={(event) => handleRotationPointerDown({ event })}
onPointerMove={(event) => handlePointerMove({ event })}
onPointerUp={(event) => handlePointerUp({ event })}
/>
</div>
);
}
function BoundingBoxOutline({
center,
outlineWidth,
outlineHeight,
rotation,
}: {
center: { x: number; y: number };
outlineWidth: number;
outlineHeight: number;
rotation: number;
}) {
return (
<div
className="pointer-events-none absolute"
style={{
left: center.x - outlineWidth / 2,
top: center.y - outlineHeight / 2,
width: outlineWidth,
height: outlineHeight,
transform: `rotate(${rotation}deg)`,
transformOrigin: "center center",
border: "1px solid white",
boxSizing: "border-box",
opacity: 0.75,
}}
/>
);
}
function CornerHandle({
corner,
screen,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
corner: Corner;
screen: { x: number; y: number };
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
const isNesw = corner === "top-right" || corner === "bottom-left";
return (
<button
type="button"
className={cn(
"absolute flex items-center justify-center outline-none",
isNesw ? "cursor-nesw-resize" : "cursor-nwse-resize",
)}
style={{
left: screen.x - CORNER_HIT_AREA_SIZE / 2,
top: screen.y - CORNER_HIT_AREA_SIZE / 2,
width: CORNER_HIT_AREA_SIZE,
height: CORNER_HIT_AREA_SIZE,
pointerEvents: "auto",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
>
<div
className="rounded-sm bg-white"
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
/>
</button>
);
}
function RotationHandle({
screen,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
screen: { x: number; y: number };
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
return (
<button
type="button"
className="absolute flex items-center justify-center rounded-full bg-white text-black shadow-sm outline-none"
style={{
left: screen.x - ROTATION_HANDLE_RADIUS,
top: screen.y - ROTATION_HANDLE_RADIUS,
width: ROTATION_HANDLE_RADIUS * 2,
height: ROTATION_HANDLE_RADIUS * 2,
pointerEvents: "auto",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
>
<HugeiconsIcon icon={Rotate01Icon} className="size-3" strokeWidth={2.5} />
</button>
);
}
@@ -0,0 +1,20 @@
import { HugeiconsIcon } from "@hugeicons/react";
import { Settings05Icon } from "@hugeicons/core-free-icons";
export function EmptyView() {
return (
<div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
<HugeiconsIcon
icon={Settings05Icon}
className="text-muted-foreground/75 size-10"
strokeWidth={1}
/>
<div className="flex flex-col gap-2 text-center">
<p className="text-lg font-medium ">It's empty here</p>
<p className="text-muted-foreground text-sm text-balance">
Click an element on the timeline to edit its properties
</p>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { useReducer, useRef } from "react";
import { evaluateMathExpression } from "@/utils/math";
function looksLikeExpression({ input }: { input: string }): boolean {
const trimmed = input.trim();
if (!trimmed) return false;
if (/[+*/]/.test(input)) return true;
const minusIndex = trimmed.indexOf("-");
return minusIndex > 0;
}
export function usePropertyDraft<T>({
displayValue: sourceDisplay,
parse,
onPreview,
onCommit,
supportsExpressions = true,
}: {
displayValue: string;
parse: (input: string) => T | null;
onPreview: (value: T) => void;
onCommit: () => void;
supportsExpressions?: boolean;
}) {
const [, forceRender] = useReducer(
(renderVersion: number) => renderVersion + 1,
0,
);
const isEditing = useRef(false);
const draft = useRef("");
return {
displayValue: isEditing.current ? draft.current : sourceDisplay,
scrubTo: (value: number) => {
const parsed = parse(String(value));
if (parsed !== null) onPreview(parsed);
},
commitScrub: onCommit,
onFocus: () => {
isEditing.current = true;
draft.current = sourceDisplay;
forceRender();
},
onChange: (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
draft.current = event.target.value;
forceRender();
const parsed = parse(event.target.value);
if (parsed !== null) {
onPreview(parsed);
}
},
onBlur: () => {
if (
supportsExpressions &&
looksLikeExpression({ input: draft.current })
) {
const evaluated = evaluateMathExpression({ input: draft.current });
if (evaluated !== null) {
const parsed = parse(String(evaluated));
if (parsed !== null) onPreview(parsed);
}
}
onCommit();
isEditing.current = false;
draft.current = "";
forceRender();
},
};
}
@@ -4,8 +4,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { AudioProperties } from "./audio-properties";
import { VideoProperties } from "./video-properties";
import { TextProperties } from "./text-properties";
import { HugeiconsIcon } from "@hugeicons/react";
import { Settings05Icon } from "@hugeicons/core-free-icons";
import { EmptyView } from "./empty-view";
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
@@ -18,9 +17,9 @@ export function PropertiesPanel() {
});
return (
<>
<div className="panel bg-background h-full rounded-sm border overflow-hidden">
{selectedElements.length > 0 ? (
<ScrollArea className="bg-panel h-full rounded-sm">
<ScrollArea className="h-full">
{elementsWithTracks.map(({ track, element }) => {
if (element.type === "text") {
return (
@@ -45,24 +44,6 @@ export function PropertiesPanel() {
) : (
<EmptyView />
)}
</>
);
}
function EmptyView() {
return (
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
<HugeiconsIcon
icon={Settings05Icon}
className="text-muted-foreground/75 size-10"
strokeWidth={1}
/>
<div className="flex flex-col gap-2 text-center">
<p className="text-lg font-medium">It's empty here</p>
<p className="text-muted-foreground text-sm text-balance">
Click an element on the timeline to edit its properties
</p>
</div>
</div>
);
}
@@ -1,91 +0,0 @@
import { useState } from "react";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowDownIcon } from "@hugeicons/core-free-icons";
interface PropertyItemProps {
direction?: "row" | "column";
children: React.ReactNode;
className?: string;
}
export function PropertyItem({
direction = "row",
children,
className,
}: PropertyItemProps) {
return (
<div
className={cn(
"flex gap-2",
direction === "row"
? "items-center justify-between gap-6"
: "flex-col gap-1.5",
className,
)}
>
{children}
</div>
);
}
export function PropertyItemLabel({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<span className={cn("text-muted-foreground text-xs", className)}>
{children}
</span>
);
}
export function PropertyItemValue({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("flex-1 text-sm", className)}>{children}</div>;
}
interface PropertyGroupProps {
title: string;
children: React.ReactNode;
defaultExpanded?: boolean;
className?: string;
titleClassName?: string;
}
export function PropertyGroup({
title,
children,
defaultExpanded = true,
className,
titleClassName,
}: PropertyGroupProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
<PropertyItem direction="column" className={cn("gap-3", className)}>
<button
type="button"
className="flex items-center gap-1.5 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<PropertyItemLabel className={cn(titleClassName)}>
{title}
</PropertyItemLabel>
<HugeiconsIcon
icon={ArrowDownIcon}
className={cn("size-3", !isExpanded && "-rotate-90")}
/>
</button>
{isExpanded && <PropertyItemValue>{children}</PropertyItemValue>}
</PropertyItem>
);
}
@@ -0,0 +1,161 @@
import { createContext, useContext, useState } from "react";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowDownIcon } from "@hugeicons/core-free-icons";
const sectionExpandedCache = new Map<string, boolean>();
interface SectionContext {
isOpen: boolean;
toggle: () => void;
collapsible: boolean;
}
const SectionCtx = createContext<SectionContext | null>(null);
function useSectionContext() {
return useContext(SectionCtx);
}
interface SectionProps {
children: React.ReactNode;
collapsible?: boolean;
defaultOpen?: boolean;
sectionKey?: string;
className?: string;
hasBorderTop?: boolean;
hasBorderBottom?: boolean;
}
export function Section({
children,
collapsible = false,
defaultOpen = true,
sectionKey,
className,
hasBorderTop = true,
hasBorderBottom = true,
}: SectionProps) {
const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined;
const [isOpen, setIsOpen] = useState(cached ?? defaultOpen);
const toggle = () => {
const next = !isOpen;
setIsOpen(next);
if (sectionKey) sectionExpandedCache.set(sectionKey, next);
};
return (
<SectionCtx.Provider value={{ isOpen, toggle, collapsible }}>
<div
className={cn(
"flex flex-col",
hasBorderTop && "border-t",
hasBorderBottom && "last:border-b",
className,
)}
>
{children}
</div>
</SectionCtx.Provider>
);
}
interface SectionHeaderProps {
title: string;
children?: React.ReactNode;
onClick?: () => void;
className?: string;
}
export function SectionHeader({
title,
children,
onClick,
className,
}: SectionHeaderProps) {
const ctx = useSectionContext();
const isCollapsible = ctx?.collapsible ?? false;
const isOpen = ctx?.isOpen ?? true;
const isInteractive = isCollapsible || !!onClick;
const handleClick = isCollapsible ? ctx?.toggle : onClick;
const content = (
<>
<span
className={cn(
"text-sm font-medium",
isOpen ? "text-foreground" : "text-muted-foreground",
)}
>
{title}
</span>
<div className="flex items-center gap-1">
{children}
{isCollapsible && (
<HugeiconsIcon
icon={ArrowDownIcon}
className={cn(
"size-3 shrink-0 transition-transform duration-200 ease-out",
isOpen
? "rotate-0 text-foreground"
: "-rotate-90 text-muted-foreground",
)}
/>
)}
</div>
</>
);
const baseClassName = cn(
"flex w-full items-center justify-between h-11 px-3.5",
className,
);
if (isInteractive) {
return (
<button
type="button"
className={cn(baseClassName, "cursor-pointer text-left")}
onClick={(event) => {
handleClick?.();
event.currentTarget.blur();
}}
>
{content}
</button>
);
}
return <div className={baseClassName}>{content}</div>;
}
export function SectionContent({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
const ctx = useSectionContext();
const isCollapsible = ctx?.collapsible ?? false;
const isOpen = ctx?.isOpen ?? true;
if (isCollapsible) {
return (
<div
className={cn(
"grid transition-[grid-template-rows] duration-100 ease-out",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<div className={cn("p-4 pt-0", className)}>{children}</div>
</div>
</div>
);
}
return <div className={cn("p-4 pt-0", className)}>{children}</div>;
}
@@ -0,0 +1,196 @@
import { useEditor } from "@/hooks/use-editor";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { clamp } from "@/utils/math";
import { NumberField } from "@/components/ui/number-field";
import {
DEFAULT_BLEND_MODE,
DEFAULT_OPACITY,
} from "@/constants/timeline-constants";
import { OcCheckerboardIcon } from "@opencut/ui/icons";
import { Fragment, useRef } from "react";
import { Section, SectionContent, SectionHeader } from "../section";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { BlendMode } from "@/types/rendering";
import type { ElementType } from "@/types/timeline";
import { HugeiconsIcon } from "@hugeicons/react";
import { RainDropIcon } from "@hugeicons/core-free-icons";
type BlendingElement = {
id: string;
opacity: number;
type: ElementType;
blendMode?: BlendMode;
};
const BLEND_MODE_GROUPS = [
[{ value: "normal", label: "Normal" }],
[
{ value: "darken", label: "Darken" },
{ value: "multiply", label: "Multiply" },
{ value: "color-burn", label: "Color Burn" },
],
[
{ value: "lighten", label: "Lighten" },
{ value: "screen", label: "Screen" },
{ value: "plus-lighter", label: "Plus Lighter" },
{ value: "color-dodge", label: "Color Dodge" },
],
[
{ value: "overlay", label: "Overlay" },
{ value: "soft-light", label: "Soft Light" },
{ value: "hard-light", label: "Hard Light" },
],
[
{ value: "difference", label: "Difference" },
{ value: "exclusion", label: "Exclusion" },
],
[
{ value: "hue", label: "Hue" },
{ value: "saturation", label: "Saturation" },
{ value: "color", label: "Color" },
{ value: "luminosity", label: "Luminosity" },
],
];
export function BlendingSection({
element,
trackId,
}: {
element: BlendingElement;
trackId: string;
}) {
const editor = useEditor();
const blendMode = element.blendMode ?? DEFAULT_BLEND_MODE;
const didSelectRef = useRef(false);
const committedBlendModeRef = useRef(blendMode);
if (!editor.timeline.isPreviewActive()) {
committedBlendModeRef.current = blendMode;
}
const previewBlendMode = (value: BlendMode) =>
editor.timeline.previewElements({
updates: [
{ trackId, elementId: element.id, updates: { blendMode: value } },
],
});
const commitBlendMode = (value: string) => {
if (editor.timeline.isPreviewActive()) {
editor.timeline.commitPreview();
} else {
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { blendMode: value as BlendMode },
},
],
});
}
didSelectRef.current = true;
};
const handleBlendModeOpenChange = (isOpen: boolean) => {
if (!isOpen) {
if (!didSelectRef.current) editor.timeline.discardPreview();
didSelectRef.current = false;
}
};
const opacity = usePropertyDraft({
displayValue: Math.round(element.opacity * 100).toString(),
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [
{ trackId, elementId: element.id, updates: { opacity: value } },
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
return (
<Section collapsible sectionKey={`${element.type}:blending`}>
<SectionHeader title="Blending" />
<SectionContent>
<div className="flex items-start gap-2">
<div className="w-1/2 space-y-1.5">
<NumberField
className="w-full"
icon={
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
}
value={opacity.displayValue}
min={0}
max={100}
onFocus={opacity.onFocus}
onChange={opacity.onChange}
onBlur={opacity.onBlur}
onScrub={opacity.scrubTo}
onScrubEnd={opacity.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { opacity: DEFAULT_OPACITY },
},
],
})
}
isDefault={element.opacity === DEFAULT_OPACITY}
dragSensitivity="slow"
/>
</div>
<div className="w-1/2 space-y-1.5">
<Select
value={committedBlendModeRef.current}
onOpenChange={handleBlendModeOpenChange}
onValueChange={commitBlendMode}
>
<SelectTrigger
icon={<HugeiconsIcon icon={RainDropIcon} />}
className="w-full"
>
<SelectValue placeholder="Select blend mode" />
</SelectTrigger>
<SelectContent className="w-36">
{BLEND_MODE_GROUPS.map((group, groupIndex) => (
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
{group.map((option) => (
<SelectItem
key={option.value}
value={option.value}
onPointerEnter={() =>
previewBlendMode(option.value as BlendMode)
}
>
{option.label}
</SelectItem>
))}
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
<SelectSeparator />
) : null}
</Fragment>
))}
</SelectContent>
</Select>
</div>
</div>
</SectionContent>
</Section>
);
}
@@ -0,0 +1,2 @@
export * from "./transform";
export * from "./blending";
@@ -0,0 +1,248 @@
import { NumberField } from "@/components/ui/number-field";
import { useEditor } from "@/hooks/use-editor";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { clamp } from "@/utils/math";
import type { ElementType, Transform } from "@/types/timeline";
import { Section, SectionContent, SectionHeader } from "../section";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import {
ArrowExpandIcon,
Link05Icon,
RotateClockwiseIcon,
} from "@hugeicons/core-free-icons";
import { useState } from "react";
import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
type TransformElement = {
id: string;
transform: Transform;
type: ElementType;
};
function parseFloat_({ input }: { input: string }): number | null {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : parsed;
}
export function TransformSection({
element,
trackId,
}: {
element: TransformElement;
trackId: string;
}) {
const editor = useEditor();
const [isScaleLocked, setIsScaleLocked] = useState(false);
const previewTransform = (transform: Partial<Transform>) => {
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { transform: { ...element.transform, ...transform } },
},
],
});
};
const commit = () => editor.timeline.commitPreview();
const positionX = usePropertyDraft({
displayValue: Math.round(element.transform.position.x).toString(),
parse: (input) => parseFloat_({ input }),
onPreview: (value) =>
previewTransform({
position: { ...element.transform.position, x: value },
}),
onCommit: commit,
});
const positionY = usePropertyDraft({
displayValue: Math.round(element.transform.position.y).toString(),
parse: (input) => parseFloat_({ input }),
onPreview: (value) =>
previewTransform({
position: { ...element.transform.position, y: value },
}),
onCommit: commit,
});
const scale = usePropertyDraft({
displayValue: Math.round(element.transform.scale * 100).toString(),
parse: (input) => {
const parsed = parseFloat_({ input });
if (parsed === null) return null;
return Math.max(parsed, 1) / 100;
},
onPreview: (value) => previewTransform({ scale: value }),
onCommit: commit,
});
const scaleFieldProps = {
className: "flex-1",
value: scale.displayValue,
onFocus: scale.onFocus,
onChange: scale.onChange,
onBlur: scale.onBlur,
dragSensitivity: "slow" as const,
onScrub: scale.scrubTo,
onScrubEnd: scale.commitScrub,
onReset: () =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
scale: DEFAULT_TRANSFORM.scale,
},
},
},
],
}),
isDefault: element.transform.scale === DEFAULT_TRANSFORM.scale,
};
const rotation = usePropertyDraft({
displayValue: Math.round(element.transform.rotate).toString(),
parse: (input) => {
const parsed = parseFloat_({ input });
if (parsed === null) return null;
return clamp({ value: parsed, min: -360, max: 360 });
},
onPreview: (value) => previewTransform({ rotate: value }),
onCommit: commit,
});
return (
<Section collapsible sectionKey={`${element.type}:transform`}>
<SectionHeader title="Transform" />
<SectionContent>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
{isScaleLocked ? (
<>
<NumberField icon="W" {...scaleFieldProps} />
<NumberField icon="H" {...scaleFieldProps} />
</>
) : (
<NumberField
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
{...scaleFieldProps}
className="flex-1"
/>
)}
<Button
variant={isScaleLocked ? "secondary" : "ghost"}
size="icon"
aria-pressed={isScaleLocked}
onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
>
<HugeiconsIcon icon={Link05Icon} />
</Button>
</div>
<div className="flex items-center gap-2">
<NumberField
icon="X"
className="flex-1"
value={positionX.displayValue}
onFocus={positionX.onFocus}
onChange={positionX.onChange}
onBlur={positionX.onBlur}
onScrub={positionX.scrubTo}
onScrubEnd={positionX.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
position: {
...element.transform.position,
x: DEFAULT_TRANSFORM.position.x,
},
},
},
},
],
})
}
isDefault={
element.transform.position.x === DEFAULT_TRANSFORM.position.x
}
/>
<NumberField
icon="Y"
className="flex-1"
value={positionY.displayValue}
onFocus={positionY.onFocus}
onChange={positionY.onChange}
onBlur={positionY.onBlur}
onScrub={positionY.scrubTo}
onScrubEnd={positionY.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
position: {
...element.transform.position,
y: DEFAULT_TRANSFORM.position.y,
},
},
},
},
],
})
}
isDefault={
element.transform.position.y === DEFAULT_TRANSFORM.position.y
}
/>
</div>
<div className="flex items-center gap-2">
<NumberField
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
className="flex-1"
value={rotation.displayValue}
onFocus={rotation.onFocus}
onChange={rotation.onChange}
onBlur={rotation.onBlur}
dragSensitivity="slow"
onScrub={rotation.scrubTo}
onScrubEnd={rotation.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
rotate: DEFAULT_TRANSFORM.rotate,
},
},
},
],
})
}
isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate}
/>
</div>
</div>
</SectionContent>
</Section>
);
}
@@ -1,35 +1,31 @@
import { Textarea } from "@/components/ui/textarea";
import { FontPicker } from "@/components/ui/font-picker";
import type { FontFamily } from "@/constants/font-constants";
import type { TextElement } from "@/types/timeline";
import { Slider } from "@/components/ui/slider";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useState, useRef } from "react";
import { PanelBaseView } from "@/components/editor/panels/panel-base-view";
import {
TEXT_PROPERTIES_TABS,
isTextPropertiesTab,
useTextPropertiesStore,
} from "@/stores/text-properties-store";
import {
PropertyItem,
PropertyItemLabel,
PropertyItemValue,
} from "./property-item";
import { NumberField } from "@/components/ui/number-field";
import { useRef } from "react";
import { Section, SectionContent, SectionHeader } from "./section";
import { ColorPicker } from "@/components/ui/color-picker";
import { cn } from "@/utils/ui";
import { capitalizeFirstLetter, uppercase } from "@/utils/string";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
import { HugeiconsIcon } from "@hugeicons/react";
import { LayoutGridIcon } from "@hugeicons/core-free-icons";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_COLOR } from "@/constants/project-constants";
import {
DEFAULT_TEXT_ELEMENT,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
} from "@/constants/text-constants";
import { usePropertyDraft } from "./hooks/use-property-draft";
import { TransformSection, BlendingSection } from "./sections";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { OcFontWeightIcon } from "@opencut/ui/icons";
import { Label } from "@/components/ui/label";
export function TextProperties({
element,
@@ -38,373 +34,229 @@ export function TextProperties({
element: TextElement;
trackId: string;
}) {
const editor = useEditor();
const { activeTab, setActiveTab } = useTextPropertiesStore();
const containerRef = useRef<HTMLDivElement>(null);
const [fontSizeInput, setFontSizeInput] = useState(
element.fontSize.toString(),
);
const [opacityInput, setOpacityInput] = useState(
Math.round(element.opacity * 100).toString(),
);
const lastSelectedColor = useRef(DEFAULT_COLOR);
const handleFontSizeChange = ({ value }: { value: string }) => {
setFontSizeInput(value);
if (value.trim() !== "") {
const parsed = parseInt(value, 10);
const fontSize = Number.isNaN(parsed)
? element.fontSize
: clamp({ value: parsed, min: 8, max: 300 });
editor.timeline.updateTextElement({
trackId,
elementId: element.id,
updates: { fontSize },
});
}
};
const handleFontSizeBlur = () => {
const parsed = parseInt(fontSizeInput, 10);
const fontSize = Number.isNaN(parsed)
? element.fontSize
: clamp({ value: parsed, min: 8, max: 300 });
setFontSizeInput(fontSize.toString());
editor.timeline.updateTextElement({
trackId,
elementId: element.id,
updates: { fontSize },
});
};
const handleOpacityChange = ({ value }: { value: string }) => {
setOpacityInput(value);
if (value.trim() !== "") {
const parsed = parseInt(value, 10);
const opacityPercent = Number.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 parsed = parseInt(opacityInput, 10);
const opacityPercent = Number.isNaN(parsed)
? Math.round(element.opacity * 100)
: clamp({ value: parsed, min: 0, max: 100 });
setOpacityInput(opacityPercent.toString());
editor.timeline.updateTextElement({
trackId,
elementId: element.id,
updates: { opacity: opacityPercent / 100 },
});
};
const handleColorChange = ({ color }: { color: string }) => {
if (color !== "transparent") {
lastSelectedColor.current = color;
}
editor.timeline.updateTextElement({
trackId,
elementId: element.id,
updates: { backgroundColor: color },
});
};
const handleTransparentToggle = ({
isTransparent,
}: {
isTransparent: boolean;
}) => {
const newColor = isTransparent ? "transparent" : lastSelectedColor.current;
editor.timeline.updateTextElement({
trackId,
elementId: element.id,
updates: { backgroundColor: newColor },
});
};
return (
<PanelBaseView
defaultTab="transform"
value={activeTab}
onValueChange={(v) => {
if (isTextPropertiesTab(v)) setActiveTab(v);
}}
ref={containerRef}
tabs={TEXT_PROPERTIES_TABS.map((t) => ({
value: t.value,
label: t.label,
content:
t.value === "transform" ? (
<div className="space-y-6"></div>
) : (
<div className="space-y-6">
<Textarea
placeholder="Name"
defaultValue={element.content}
className="bg-panel-accent min-h-20"
onChange={(e) =>
editor.timeline.updateTextElement({
<div className="flex h-full flex-col">
<ContentSection element={element} trackId={trackId} />
<TransformSection element={element} trackId={trackId} />
<BlendingSection element={element} trackId={trackId} />
<FontSection element={element} trackId={trackId} />
<ColorSection element={element} trackId={trackId} />
</div>
);
}
function ContentSection({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) {
const editor = useEditor();
const content = usePropertyDraft({
displayValue: element.content,
parse: (input) => input,
onPreview: (value) =>
editor.timeline.previewElements({
updates: [
{ trackId, elementId: element.id, updates: { content: value } },
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
return (
<Section collapsible sectionKey="text:content" hasBorderTop={false}>
<SectionHeader title="Content" />
<SectionContent>
<Textarea
placeholder="Name"
value={content.displayValue}
className="min-h-20"
onFocus={content.onFocus}
onChange={content.onChange}
onBlur={content.onBlur}
/>
</SectionContent>
</Section>
);
}
function FontSection({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) {
const editor = useEditor();
const fontSize = usePropertyDraft({
displayValue: element.fontSize.toString(),
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
return clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [
{ trackId, elementId: element.id, updates: { fontSize: value } },
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
return (
<Section collapsible sectionKey="text:font">
<SectionHeader title="Font" />
<SectionContent>
<div className="flex flex-col gap-2">
<FontPicker
defaultValue={element.fontFamily}
onValueChange={(value) =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { content: e.target.value },
})
}
/>
<PropertyItem direction="column">
<PropertyItemLabel>Font</PropertyItemLabel>
<PropertyItemValue>
<FontPicker
defaultValue={element.fontFamily}
onValueChange={(value: FontFamily) =>
editor.timeline.updateTextElement({
trackId,
elementId: element.id,
updates: { fontFamily: value },
})
}
/>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Style</PropertyItemLabel>
<PropertyItemValue>
<div className="flex items-center gap-2">
<Button
variant={
element.fontWeight === "bold" ? "default" : "outline"
}
size="sm"
onClick={() =>
editor.timeline.updateTextElement({
trackId,
elementId: element.id,
updates: {
fontWeight:
element.fontWeight === "bold" ? "normal" : "bold",
},
})
}
className="h-8 px-3 font-bold"
>
B
</Button>
<Button
variant={
element.fontStyle === "italic" ? "default" : "outline"
}
size="sm"
onClick={() =>
editor.timeline.updateTextElement({
trackId,
elementId: element.id,
updates: {
fontStyle:
element.fontStyle === "italic"
? "normal"
: "italic",
},
})
}
className="h-8 px-3 italic"
>
I
</Button>
<Button
variant={
element.textDecoration === "underline"
? "default"
: "outline"
}
size="sm"
onClick={() =>
editor.timeline.updateTextElement({
trackId,
elementId: element.id,
updates: {
textDecoration:
element.textDecoration === "underline"
? "none"
: "underline",
},
})
}
className="h-8 px-3 underline"
>
U
</Button>
<Button
variant={
element.textDecoration === "line-through"
? "default"
: "outline"
}
size="sm"
onClick={() =>
editor.timeline.updateTextElement({
trackId,
elementId: element.id,
updates: {
textDecoration:
element.textDecoration === "line-through"
? "none"
: "line-through",
},
})
}
className="h-8 px-3 line-through"
>
S
</Button>
</div>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Font size</PropertyItemLabel>
<PropertyItemValue>
<div className="flex items-center gap-2">
<Slider
value={[element.fontSize]}
min={8}
max={300}
step={1}
onValueChange={([value]) => {
editor.timeline.updateTextElement({
trackId,
elementId: element.id,
updates: { fontSize: value },
});
setFontSizeInput(value.toString());
}}
className="w-full"
/>
<Input
type="number"
value={fontSizeInput}
min={8}
max={300}
onChange={(e) =>
handleFontSizeChange({ value: e.target.value })
}
onBlur={handleFontSizeBlur}
className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
</div>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Color</PropertyItemLabel>
<PropertyItemValue>
<ColorPicker
value={uppercase({
string: (element.color || "FFFFFF").replace("#", ""),
})}
onChange={(color) => {
editor.timeline.updateTextElement({
trackId,
elementId: element.id,
updates: { color: `#${color}` },
});
}}
containerRef={containerRef}
/>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Opacity</PropertyItemLabel>
<PropertyItemValue>
<div className="flex items-center gap-2">
<Slider
value={[element.opacity * 100]}
min={0}
max={100}
step={1}
onValueChange={([value]) => {
editor.timeline.updateTextElement({
trackId,
elementId: element.id,
updates: { opacity: value / 100 },
});
setOpacityInput(value.toString());
}}
className="w-full"
/>
<Input
type="number"
value={opacityInput}
min={0}
max={100}
onChange={(e) =>
handleOpacityChange({ value: e.target.value })
}
onBlur={handleOpacityBlur}
className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
</div>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Background</PropertyItemLabel>
<PropertyItemValue>
<div className="flex items-center gap-2">
<ColorPicker
value={capitalizeFirstLetter({
string:
element.backgroundColor === "transparent"
? lastSelectedColor.current.replace("#", "")
: element.backgroundColor.replace("#", ""),
})}
onChange={(color) =>
handleColorChange({ color: `#${color}` })
}
containerRef={containerRef}
className={
element.backgroundColor === "transparent"
? "pointer-events-none opacity-50"
: ""
}
/>
updates: { fontFamily: value },
},
],
})
}
/>
<Select value={element.fontWeight}>
<SelectTrigger className="w-full" icon={<OcFontWeightIcon />}>
<SelectValue placeholder="Select weight" />
</SelectTrigger>
<SelectContent>
<SelectItem value="100">Thin</SelectItem>
<SelectItem value="200">Extra Light</SelectItem>
<SelectItem value="300">Light</SelectItem>
<SelectItem value="400">Normal</SelectItem>
<SelectItem value="500">Medium</SelectItem>
<SelectItem value="600">Semi Bold</SelectItem>
<SelectItem value="700">Bold</SelectItem>
<SelectItem value="800">Extra Bold</SelectItem>
<SelectItem value="900">Black</SelectItem>
</SelectContent>
</Select>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() =>
handleTransparentToggle({
isTransparent:
element.backgroundColor !== "transparent",
})
}
className="bg-panel-accent size-9 overflow-hidden rounded-full p-0"
>
<HugeiconsIcon
icon={LayoutGridIcon}
className={cn(
"text-foreground",
element.backgroundColor === "transparent" &&
"text-primary",
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent>Transparent background</TooltipContent>
</Tooltip>
</div>
</PropertyItemValue>
</PropertyItem>
</div>
),
}))}
/>
<Label>Font size</Label>
<div className="flex items-center gap-2">
<Slider
value={[element.fontSize]}
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
step={1}
onValueChange={([value]) =>
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { fontSize: value },
},
],
})
}
onValueCommit={() => editor.timeline.commitPreview()}
className="w-full"
/>
<NumberField
className="w-18 shrink-0"
value={fontSize.displayValue}
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
onFocus={fontSize.onFocus}
onChange={fontSize.onChange}
onBlur={fontSize.onBlur}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
fontSize: DEFAULT_TEXT_ELEMENT.fontSize,
},
},
],
})
}
isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
/>
</div>
</div>
</SectionContent>
</Section>
);
}
function ColorSection({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) {
const editor = useEditor();
const lastSelectedColor = useRef(DEFAULT_COLOR);
return (
<Section collapsible sectionKey="text:color" hasBorderBottom={false}>
<SectionHeader title="Color" />
<SectionContent>
<div className="flex flex-col gap-6">
<ColorPicker
value={uppercase({
string: (element.color || "FFFFFF").replace("#", ""),
})}
onChange={(color) =>
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { color: `#${color}` },
},
],
})
}
onChangeEnd={() => editor.timeline.commitPreview()}
/>
<ColorPicker
value={
element.backgroundColor === "transparent"
? lastSelectedColor.current.replace("#", "")
: element.backgroundColor.replace("#", "")
}
onChange={(color) => {
const hexColor = `#${color}`;
if (color !== "transparent") {
lastSelectedColor.current = hexColor;
}
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { backgroundColor: hexColor },
},
],
});
}}
onChangeEnd={() => editor.timeline.commitPreview()}
className={
element.backgroundColor === "transparent"
? "pointer-events-none opacity-50"
: ""
}
/>
</div>
</SectionContent>
</Section>
);
}
@@ -0,0 +1,416 @@
"use client";
import { useEffect, useState } from "react";
import type { EditorCore } from "@/core";
import { useEditor } from "@/hooks/use-editor";
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
import {
DEFAULT_BOOKMARK_COLOR,
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
import { getSnappedSeekTime } from "@/lib/time";
import {
ArrowTurnBackwardIcon,
Delete02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { Bookmark } from "@/types/timeline";
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { ColorPicker } from "@/components/ui/color-picker";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
const PIXELS_PER_SECOND = TIMELINE_CONSTANTS.PIXELS_PER_SECOND;
const MIN_BOOKMARK_WIDTH_PX = 2;
const BOOKMARK_MARKER_WIDTH_PX = 12;
const BOOKMARK_MARKER_HEIGHT_PX = 15;
const BOOKMARK_HALF_WIDTH_PX = BOOKMARK_MARKER_WIDTH_PX / 2;
const BOOKMARK_MARKER_CLIP_PATH =
"polygon(50% 100%, 12% 72%, 12% 10%, 88% 10%, 88% 72%)";
function seekToBookmarkTime({
editor,
time,
}: {
editor: EditorCore;
time: number;
}) {
const activeProject = editor.project.getActive();
const duration = editor.timeline.getTotalDuration();
const fps = activeProject?.settings.fps ?? DEFAULT_FPS;
const snappedTime = getSnappedSeekTime({ rawTime: time, duration, fps });
editor.playback.seek({ time: snappedTime });
}
interface TimelineBookmarksRowProps {
zoomLevel: number;
dynamicTimelineWidth: number;
dragState: BookmarkDragState;
onBookmarkMouseDown: (params: {
event: React.MouseEvent;
bookmark: Bookmark;
}) => void;
handleWheel: (event: React.WheelEvent) => void;
handleTimelineContentClick: (event: React.MouseEvent) => void;
handleRulerTrackingMouseDown: (event: React.MouseEvent) => void;
handleRulerMouseDown: (event: React.MouseEvent) => void;
}
export function TimelineBookmarksRow({
zoomLevel,
dynamicTimelineWidth,
dragState,
onBookmarkMouseDown,
handleWheel,
handleTimelineContentClick,
handleRulerTrackingMouseDown,
handleRulerMouseDown,
}: TimelineBookmarksRowProps) {
const editor = useEditor();
const activeScene = editor.scenes.getActiveScene();
return (
<div className="relative h-4 flex-1 overflow-hidden">
<button
className="relative h-4 w-full cursor-default select-none border-0 bg-transparent p-0"
style={{
width: `${dynamicTimelineWidth}px`,
}}
aria-label="Timeline ruler"
type="button"
onWheel={handleWheel}
onClick={(event) => {
if (!event.currentTarget.contains(event.target as Node)) return;
handleTimelineContentClick(event);
}}
onMouseDown={(event) => {
if (!event.currentTarget.contains(event.target as Node)) return;
handleRulerMouseDown(event);
handleRulerTrackingMouseDown(event);
}}
>
{activeScene.bookmarks.map((bookmark) => (
<TimelineBookmark
key={`bookmark-${bookmark.time}`}
bookmark={bookmark}
zoomLevel={zoomLevel}
dragState={dragState}
onBookmarkMouseDown={onBookmarkMouseDown}
/>
))}
</button>
</div>
);
}
function TimelineBookmark({
bookmark,
zoomLevel,
dragState,
onBookmarkMouseDown,
}: {
bookmark: Bookmark;
zoomLevel: number;
dragState: BookmarkDragState;
onBookmarkMouseDown: (params: {
event: React.MouseEvent;
bookmark: Bookmark;
}) => void;
}) {
const editor = useEditor();
const duration = editor.timeline.getTotalDuration();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const isDragging =
dragState.isDragging &&
dragState.bookmarkTime !== null &&
Math.abs(dragState.bookmarkTime - bookmark.time) < BOOKMARK_TIME_EPSILON;
const displayTime = isDragging ? dragState.currentTime : bookmark.time;
const time = bookmark.time;
const bookmarkDuration = bookmark.duration ?? 0;
const durationWidth =
bookmarkDuration > 0 ? bookmarkDuration * PIXELS_PER_SECOND * zoomLevel : 0;
const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX;
const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0);
const left = displayTime * PIXELS_PER_SECOND * zoomLevel;
const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX;
const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0);
const iconColor = bookmark.color ?? DEFAULT_BOOKMARK_COLOR;
const handleSeek = () => seekToBookmarkTime({ editor, time });
const handleClick = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (event.detail === 2) {
setIsPopoverOpen(true);
} else {
handleSeek();
}
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
handleSeek();
};
const handleMouseDown = (event: React.MouseEvent) => {
onBookmarkMouseDown({ event, bookmark });
event.preventDefault();
event.stopPropagation();
};
return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverAnchor asChild>
<button
className={`absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0 ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
style={{
left: `${bookmarkLeft}px`,
width: `${bookmarkWidth}px`,
}}
aria-label={`Bookmark at ${time.toFixed(1)}s`}
type="button"
onMouseDown={handleMouseDown}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
{hasDurationRange ? (
<div
className="absolute opacity-30"
style={{
top: 1.5,
height: BOOKMARK_MARKER_HEIGHT_PX - 2.5,
left: BOOKMARK_HALF_WIDTH_PX,
width: durationWidth,
backgroundColor: iconColor,
}}
/>
) : null}
<div
className="absolute top-0 overflow-hidden"
style={{
left: 0,
width: BOOKMARK_HALF_WIDTH_PX,
height: BOOKMARK_MARKER_HEIGHT_PX,
}}
>
<div
className="absolute inset-0"
style={{
width: BOOKMARK_MARKER_WIDTH_PX,
height: BOOKMARK_MARKER_HEIGHT_PX,
clipPath: BOOKMARK_MARKER_CLIP_PATH,
backgroundColor: "hsl(var(--background))",
}}
/>
<div
className="absolute"
style={{
top: 1,
left: 1,
width: BOOKMARK_MARKER_WIDTH_PX - 2,
height: BOOKMARK_MARKER_HEIGHT_PX - 2,
clipPath: BOOKMARK_MARKER_CLIP_PATH,
backgroundColor: iconColor,
}}
/>
</div>
<div
className="absolute top-0 overflow-hidden"
style={{
left: rightHalfLeft,
width: BOOKMARK_HALF_WIDTH_PX,
height: BOOKMARK_MARKER_HEIGHT_PX,
}}
>
<div
className="absolute top-0"
style={{
left: -BOOKMARK_HALF_WIDTH_PX,
width: BOOKMARK_MARKER_WIDTH_PX,
height: BOOKMARK_MARKER_HEIGHT_PX,
clipPath: BOOKMARK_MARKER_CLIP_PATH,
backgroundColor: "hsl(var(--background))",
}}
/>
<div
className="absolute"
style={{
top: 1,
left: 1 - BOOKMARK_HALF_WIDTH_PX,
width: BOOKMARK_MARKER_WIDTH_PX - 2,
height: BOOKMARK_MARKER_HEIGHT_PX - 2,
clipPath: BOOKMARK_MARKER_CLIP_PATH,
backgroundColor: iconColor,
}}
/>
</div>
</button>
</PopoverAnchor>
<PopoverContent
className="w-64 flex flex-col gap-3 p-3"
align="start"
side="bottom"
sideOffset={8}
onOpenAutoFocus={(event) => event.preventDefault()}
>
<BookmarkPopoverContent
bookmark={bookmark}
time={time}
timelineDuration={duration}
onPopoverClose={() => setIsPopoverOpen(false)}
/>
</PopoverContent>
</Popover>
);
}
function BookmarkPopoverContent({
bookmark,
time,
timelineDuration,
onPopoverClose,
}: {
bookmark: Bookmark;
time: number;
timelineDuration: number;
onPopoverClose: () => void;
}) {
const editor = useEditor();
const [draftColorHex, setDraftColorHex] = useState(
(bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
);
useEffect(() => {
setDraftColorHex(
(bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
);
}, [bookmark.color]);
const handleRemove = () => {
editor.scenes.removeBookmark({ time });
onPopoverClose();
};
const handleUpdate = ({
note,
color,
duration,
}: Partial<{ note: string; color: string; duration: number }>) => {
const updates: Partial<{ note: string; color: string; duration: number }> =
{};
if (note !== undefined && note !== bookmark.note) updates.note = note;
if (
color !== undefined &&
color.toUpperCase() !== (bookmark.color ?? "").toUpperCase()
) {
updates.color = color;
}
if (duration !== undefined && duration !== bookmark.duration) {
updates.duration = duration;
}
if (Object.keys(updates).length === 0) return;
editor.scenes.updateBookmark({ time, updates });
};
return (
<>
<div className="flex flex-col gap-2">
<Label className="text-xs">Note</Label>
<Input
placeholder="Add a note..."
value={bookmark.note ?? ""}
onChange={(event) => handleUpdate({ note: event.target.value })}
className="h-8 text-sm"
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-xs">Color</Label>
<div className="relative">
<ColorPicker
value={uppercase({ string: draftColorHex })}
onChange={(color) => setDraftColorHex(uppercase({ string: color }))}
onChangeEnd={(color) =>
handleUpdate({ color: `#${uppercase({ string: color })}` })
}
className="bg-background border"
/>
{bookmark.color &&
bookmark.color.replace(/^#/, "").toUpperCase() !==
DEFAULT_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && (
<Button
type="button"
variant="text"
size="text"
aria-label="Reset to default color"
className="absolute top-1/2 right-1 -translate-y-1/2 mr-1"
onClick={() =>
editor.scenes.updateBookmark({
time,
updates: { color: undefined },
})
}
>
<HugeiconsIcon
icon={ArrowTurnBackwardIcon}
className="!size-3.5"
/>
</Button>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<Label className="text-xs">Duration</Label>
<div className="flex items-center gap-1.5">
<Input
type="number"
min={0}
step={0.1}
value={bookmark.duration ?? 0}
onChange={(event) => {
const parsed = parseFloat(event.target.value);
const value = Number.isNaN(parsed)
? 0
: clamp({
value: parsed,
min: 0,
max: Math.max(0, timelineDuration - time),
});
handleUpdate({ duration: value });
}}
className="h-8 text-sm"
containerClassName="w-full"
/>
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="text-destructive hover:bg-destructive/10"
onClick={handleRemove}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
handleRemove();
}
}}
aria-label="delete bookmark"
>
<HugeiconsIcon icon={Delete02Icon} className="!size-3.5" />
Delete
</Button>
</>
);
}
@@ -15,12 +15,12 @@ import {
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "../../ui/context-menu";
} from "../../../ui/context-menu";
import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
import { useState, useRef, useCallback } from "react";
import { TimelineTrackContent } from "./timeline-track";
import { TimelinePlayhead } from "./timeline-playhead";
import { SelectionBox } from "../selection-box";
import { SelectionBox } from "../../selection-box";
import { useSelectionBox } from "@/hooks/timeline/use-selection-box";
import { SnapIndicator } from "./snap-indicator";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
@@ -47,6 +47,8 @@ import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
import { TimelineRuler } from "./timeline-ruler";
import { TimelineBookmarksRow } from "./bookmarks";
import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useTimelineStore } from "@/stores/timeline-store";
import { useEditor } from "@/hooks/use-editor";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
@@ -126,6 +128,17 @@ export function Timeline() {
onSnapPointChange: handleSnapPointChange,
});
const {
dragState: bookmarkDragState,
handleBookmarkMouseDown,
lastMouseXRef: bookmarkLastMouseXRef,
} = useBookmarkDrag({
zoomLevel,
scrollRef: tracksScrollRef,
snappingEnabled,
onSnapPointChange: handleSnapPointChange,
});
const { handleRulerMouseDown: handlePlayheadRulerMouseDown } =
useTimelinePlayhead({
zoomLevel,
@@ -168,10 +181,18 @@ export function Timeline() {
containerWidth,
);
useEdgeAutoScroll({
isActive: bookmarkDragState.isDragging,
getMouseClientX: () => bookmarkLastMouseXRef.current,
rulerScrollRef: tracksScrollRef,
tracksScrollRef,
contentWidth: dynamicTimelineWidth,
});
const showSnapIndicator =
snappingEnabled &&
currentSnapPoint !== null &&
(dragState.isDragging || isResizing);
(dragState.isDragging || bookmarkDragState.isDragging || isResizing);
const {
handleTracksMouseDown,
@@ -201,7 +222,7 @@ export function Timeline() {
return (
<section
className={
"bg-panel relative flex h-full flex-col overflow-hidden rounded-sm"
"panel bg-background relative flex h-full flex-col overflow-hidden rounded-sm border"
}
{...dragProps}
aria-label="Timeline"
@@ -226,17 +247,17 @@ export function Timeline() {
isVisible={showSnapIndicator}
/>
<div className="flex flex-1 overflow-hidden">
<div className="bg-panel flex w-28 shrink-0 flex-col border-r">
<div className="bg-panel flex h-4 items-center justify-between px-3">
<div className="bg-background flex w-28 shrink-0 flex-col border-r">
<div className="bg-background flex h-4 items-center justify-between px-3">
<span className="opacity-0">.</span>
</div>
<div className="bg-panel flex h-4 items-center justify-between px-3">
<div className="bg-background flex h-4 items-center justify-between px-3">
<span className="opacity-0">.</span>
</div>
{tracks.length > 0 && (
<div
ref={trackLabelsRef}
className="bg-panel flex-1 overflow-y-auto"
className="bg-background flex-1 overflow-y-auto"
style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP_PX }}
>
<ScrollArea className="size-full" ref={trackLabelsScrollRef}>
@@ -351,12 +372,13 @@ export function Timeline() {
>
<div
ref={timelineHeaderRef}
className="bg-panel sticky top-0 z-30 flex flex-col"
className="bg-background sticky top-0 flex flex-col"
>
<TimelineRuler
zoomLevel={zoomLevel}
dynamicTimelineWidth={dynamicTimelineWidth}
rulerRef={rulerRef}
tracksScrollRef={tracksScrollRef}
handleWheel={handleWheel}
handleTimelineContentClick={handleRulerClick}
handleRulerTrackingMouseDown={handleRulerMouseDown}
@@ -365,6 +387,8 @@ export function Timeline() {
<TimelineBookmarksRow
zoomLevel={zoomLevel}
dynamicTimelineWidth={dynamicTimelineWidth}
dragState={bookmarkDragState}
onBookmarkMouseDown={handleBookmarkMouseDown}
handleWheel={handleWheel}
handleTimelineContentClick={handleRulerClick}
handleRulerTrackingMouseDown={handleRulerMouseDown}
@@ -423,12 +447,16 @@ export function Timeline() {
onResizeStateChange={handleResizeStateChange}
onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick}
onTrackMouseDown={handleSelectionMouseDown}
onTrackMouseDown={(event) => {
handleSelectionMouseDown(event);
handleTracksMouseDown(event);
}}
onTrackClick={handleTracksClick}
shouldIgnoreClick={shouldIgnoreClick}
/>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-200 w-40">
<ContextMenuContent className="w-40">
<ContextMenuItem
icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
onClick={(e) => {
@@ -38,7 +38,7 @@ export function SnapIndicator({
return (
<div
className="pointer-events-none absolute z-90"
className="pointer-events-none absolute"
style={{
left: `${leftPosition}px`,
top: topPosition,
@@ -19,7 +19,7 @@ import {
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "../../ui/context-menu";
} from "../../../ui/context-menu";
import type {
TimelineElement as TimelineElementType,
TimelineTrack,
@@ -29,8 +29,8 @@ import type { MediaAsset } from "@/types/assets";
import { mediaSupportsAudio } from "@/lib/media/media-utils";
import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { resolveStickerId } from "@/lib/stickers";
import Image from "next/image";
import { Filmstrip } from "./filmstrip";
import {
ScissorIcon,
Delete02Icon,
@@ -140,9 +140,7 @@ export function TimelineElement({
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className={`absolute top-0 h-full select-none ${
isBeingDragged ? "z-30" : "z-10"
}`}
className={`absolute top-0 h-full select-none`}
style={{
left: `${elementLeft}px`,
width: `${elementWidth}px`,
@@ -156,18 +154,16 @@ export function TimelineElement({
element={element}
track={track}
isSelected={isSelected}
isBeingDragged={isBeingDragged}
hasAudio={hasAudio}
isMuted={isMuted}
mediaAssets={mediaAssets}
zoomLevel={zoomLevel}
onElementClick={onElementClick}
onElementMouseDown={onElementMouseDown}
handleResizeStart={handleResizeStart}
/>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-200 w-64">
<ContextMenuContent className="w-64">
<ActionMenuItem
action="split"
icon={<HugeiconsIcon icon={ScissorIcon} />}
@@ -229,11 +225,9 @@ function ElementInner({
element,
track,
isSelected,
isBeingDragged,
hasAudio,
isMuted,
mediaAssets,
zoomLevel,
onElementClick,
onElementMouseDown,
handleResizeStart,
@@ -241,18 +235,16 @@ function ElementInner({
element: TimelineElementType;
track: TimelineTrack;
isSelected: boolean;
isBeingDragged: boolean;
hasAudio: boolean;
isMuted: boolean;
mediaAssets: MediaAsset[];
zoomLevel: number;
onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
onElementMouseDown: (
e: React.MouseEvent,
element: TimelineElementType,
) => void;
handleResizeStart: (params: {
e: React.MouseEvent;
event: React.MouseEvent;
elementId: string;
side: "left" | "right";
}) => void;
@@ -263,7 +255,7 @@ function ElementInner({
{
type: track.type,
},
)} ${isBeingDragged ? "z-30" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
)} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
>
<button
type="button"
@@ -277,7 +269,6 @@ function ElementInner({
track={track}
isSelected={isSelected}
mediaAssets={mediaAssets}
zoomLevel={zoomLevel}
/>
</div>
@@ -326,7 +317,7 @@ function ResizeHandle({
side: "left" | "right";
elementId: string;
handleResizeStart: (params: {
e: React.MouseEvent;
event: React.MouseEvent;
elementId: string;
side: "left" | "right";
}) => void;
@@ -335,8 +326,8 @@ function ResizeHandle({
return (
<button
type="button"
className={`bg-primary absolute top-0 bottom-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
onMouseDown={(e) => handleResizeStart({ e, elementId, side })}
className={`bg-primary absolute top-0 bottom-0 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
onMouseDown={(event) => handleResizeStart({ event, elementId, side })}
aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
>
<div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" />
@@ -349,13 +340,11 @@ function ElementContent({
track,
isSelected,
mediaAssets,
zoomLevel,
}: {
element: TimelineElementType;
track: TimelineTrack;
isSelected: boolean;
mediaAssets: MediaAsset[];
zoomLevel: number;
}) {
if (element.type === "text") {
return (
@@ -369,7 +358,10 @@ function ElementContent({
return (
<div className="flex size-full items-center gap-2 pl-2">
<Image
src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`}
src={resolveStickerId({
stickerId: element.stickerId,
options: { width: 20, height: 20 },
})}
alt={element.name}
className="size-5 shrink-0"
width={20}
@@ -421,10 +413,14 @@ function ElementContent({
);
}
if (mediaAsset.type === "image") {
if (
mediaAsset.type === "image" ||
(mediaAsset.type === "video" && mediaAsset.thumbnailUrl)
) {
const trackHeight = getTrackHeight({ type: track.type });
const tileWidth = trackHeight * (16 / 9);
const imageUrl = mediaAsset.url;
const imageUrl =
mediaAsset.type === "image" ? mediaAsset.url : mediaAsset.thumbnailUrl;
return (
<div className="flex size-full items-center justify-center">
@@ -448,29 +444,6 @@ function ElementContent({
);
}
if (mediaAsset.type === "video") {
const trackHeight = getTrackHeight({ type: track.type });
const trimStart = element.trimStart ?? 0;
return (
<div className="flex size-full items-center justify-center">
<div
className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`}
>
<Filmstrip
mediaId={element.mediaId}
file={mediaAsset.file}
duration={element.duration}
trimStart={trimStart}
trackHeight={trackHeight}
zoomLevel={zoomLevel}
fallbackThumbnailUrl={mediaAsset.thumbnailUrl}
/>
</div>
</div>
);
}
return (
<span className="text-foreground/80 truncate text-xs">{element.name}</span>
);
@@ -72,20 +72,22 @@ export function TimelinePlayhead({
aria-valuemax={duration}
aria-valuenow={playheadPosition}
tabIndex={0}
className="pointer-events-auto absolute z-60"
className="pointer-events-none absolute z-5"
style={{
left: `${leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
width: "2px",
}}
onMouseDown={handlePlayheadMouseDown}
onKeyDown={handlePlayheadKeyDown}
>
<div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" />
<div className="bg-foreground pointer-events-none absolute left-0 h-full w-0.5" />
<div
className={`absolute top-1 left-1/2 size-3 -translate-x-1/2 transform rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
<button
type="button"
aria-label="Drag playhead"
className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
onMouseDown={handlePlayheadMouseDown}
/>
</div>
);
@@ -1,14 +1,16 @@
import type { JSX } from "react";
import { type JSX, useLayoutEffect, useRef } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
import { useEditor } from "@/hooks/use-editor";
import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils";
import { useScrollPosition } from "@/hooks/timeline/use-scroll-position";
import { TimelineTick } from "./timeline-tick";
interface TimelineRulerProps {
zoomLevel: number;
dynamicTimelineWidth: number;
rulerRef: React.Ref<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLElement | null>;
handleWheel: (e: React.WheelEvent) => void;
handleTimelineContentClick: (e: React.MouseEvent) => void;
handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
@@ -19,6 +21,7 @@ export function TimelineRuler({
zoomLevel,
dynamicTimelineWidth,
rulerRef,
tracksScrollRef,
handleWheel,
handleTimelineContentClick,
handleRulerTrackingMouseDown,
@@ -37,8 +40,47 @@ export function TimelineRuler({
});
const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1;
const { scrollLeft, viewportWidth } = useScrollPosition({
scrollRef: tracksScrollRef,
});
/**
* widens the virtualization buffer during zoom transitions.
* useScrollPosition lags one frame behind the scroll adjustment
* that useLayoutEffect applies after a zoom change.
*/
const prevZoomRef = useRef(zoomLevel);
const isZoomTransition = zoomLevel !== prevZoomRef.current;
const bufferPx = isZoomTransition
? Math.max(200, (scrollLeft + viewportWidth) * 0.15)
: 200;
useLayoutEffect(() => {
prevZoomRef.current = zoomLevel;
}, [zoomLevel]);
const visibleStartTime = Math.max(
0,
(scrollLeft - bufferPx) / pixelsPerSecond,
);
const visibleEndTime =
(scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond;
const startTickIndex = Math.max(
0,
Math.floor(visibleStartTime / tickIntervalSeconds),
);
const endTickIndex = Math.min(
tickCount - 1,
Math.ceil(visibleEndTime / tickIntervalSeconds),
);
const timelineTicks: Array<JSX.Element> = [];
for (let tickIndex = 0; tickIndex < tickCount; tickIndex += 1) {
for (
let tickIndex = startTickIndex;
tickIndex <= endTickIndex;
tickIndex += 1
) {
const time = tickIndex * tickIntervalSeconds;
if (time > effectiveDuration) break;
@@ -6,7 +6,7 @@ import {
TooltipContent,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { SkipBack, SplitSquareHorizontal } from "lucide-react";
import { SplitSquareHorizontal } from "lucide-react";
import {
SplitButton,
SplitButtonLeft,
@@ -14,10 +14,9 @@ import {
SplitButtonSeparator,
} from "@/components/ui/split-button";
import { Slider } from "@/components/ui/slider";
import { formatTimeCode } from "@/lib/time";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { EditableTimecode } from "@/components/editable-timecode";
import { ScenesView } from "../scenes-view";
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
import { ScenesView } from "../../scenes-view";
import { type TAction, invokeAction } from "@/lib/actions";
import { cn } from "@/utils/ui";
import { useTimelineStore } from "@/stores/timeline-store";
@@ -31,8 +30,6 @@ import {
Link04Icon,
SearchAddIcon,
SearchMinusIcon,
PauseIcon,
PlayIcon,
Copy01Icon,
AlignLeftIcon,
AlignRightIcon,
@@ -54,9 +51,9 @@ export function TimelineToolbar({
direction === "in"
? Math.min(
TIMELINE_CONSTANTS.ZOOM_MAX,
zoomLevel + TIMELINE_CONSTANTS.ZOOM_STEP,
zoomLevel * TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR,
)
: Math.max(minZoom, zoomLevel - TIMELINE_CONSTANTS.ZOOM_STEP);
: Math.max(minZoom, zoomLevel / TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR);
setZoomLevel({ zoom: newZoomLevel });
};
@@ -81,7 +78,6 @@ export function TimelineToolbar({
function ToolbarLeftSection() {
const editor = useEditor();
const currentTime = editor.playback.getCurrentTime();
const isPlaying = editor.playback.getIsPlaying();
const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime });
const handleAction = ({
@@ -98,46 +94,16 @@ function ToolbarLeftSection() {
return (
<div className="flex items-center gap-1">
<TooltipProvider delayDuration={500}>
<ToolbarButton
icon={
isPlaying ? (
<HugeiconsIcon icon={PauseIcon} />
) : (
<HugeiconsIcon icon={PlayIcon} />
)
}
tooltip={isPlaying ? "Pause" : "Play"}
onClick={({ event }) =>
handleAction({ action: "toggle-play", event })
}
/>
<ToolbarButton
icon={<SkipBack />}
tooltip="Go to start"
onClick={({ event }) => handleAction({ action: "goto-start", event })}
/>
<div className="bg-border mx-1 h-6 w-px" />
<TimeDisplay />
<div className="bg-border mx-1 h-6 w-px" />
<ToolbarButton
icon={<HugeiconsIcon icon={ScissorIcon} />}
tooltip="Split element"
onClick={({ event }) =>
handleAction({ action: "split", event })
}
onClick={({ event }) => handleAction({ action: "split", event })}
/>
<ToolbarButton
icon={<HugeiconsIcon icon={AlignLeftIcon} />}
tooltip="Split left"
onClick={({ event }) =>
handleAction({ action: "split-left", event })
}
onClick={({ event }) => handleAction({ action: "split-left", event })}
/>
<ToolbarButton
@@ -182,12 +148,8 @@ function ToolbarLeftSection() {
<Tooltip>
<ToolbarButton
icon={
<HugeiconsIcon
icon={Bookmark02Icon}
className={currentBookmarked ? "fill-primary text-primary" : ""}
/>
}
icon={<HugeiconsIcon icon={Bookmark02Icon} />}
isActive={currentBookmarked}
tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"}
onClick={({ event }) =>
handleAction({ action: "toggle-bookmark", event })
@@ -199,34 +161,6 @@ function ToolbarLeftSection() {
);
}
function TimeDisplay() {
const editor = useEditor();
const currentTime = editor.playback.getCurrentTime();
const totalDuration = editor.timeline.getTotalDuration();
const fps = editor.project.getActive().settings.fps;
return (
<div className="flex flex-row items-center justify-center px-2">
<EditableTimecode
time={currentTime}
duration={totalDuration}
format="HH:MM:SS:FF"
fps={fps}
onTimeChange={({ time }) => editor.playback.seek({ time })}
className="text-center"
/>
<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: totalDuration,
format: "HH:MM:SS:FF",
fps,
})}
</div>
</div>
);
}
function SceneSelector() {
const editor = useEditor();
const currentScene = editor.scenes.getActiveScene();
@@ -237,7 +171,7 @@ function SceneSelector() {
<SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
<SplitButtonSeparator />
<ScenesView>
<SplitButtonRight onClick={() => {}} type="button">
<SplitButtonRight onClick={() => {}}>
<HugeiconsIcon icon={Layers01Icon} className="size-4" />
</SplitButtonRight>
</ScenesView>
@@ -268,26 +202,15 @@ function ToolbarRightSection({
<div className="flex items-center gap-1">
<TooltipProvider delayDuration={500}>
<ToolbarButton
icon={
<HugeiconsIcon
icon={MagnetIcon}
className={cn(snappingEnabled ? "text-primary" : "")}
/>
}
icon={<HugeiconsIcon icon={MagnetIcon} />}
isActive={snappingEnabled}
tooltip="Auto snapping"
onClick={() => toggleSnapping()}
/>
<ToolbarButton
icon={
<HugeiconsIcon
icon={Link04Icon}
className={cn(
rippleEditingEnabled ? "text-primary" : "",
"scale-110",
)}
/>
}
icon={<HugeiconsIcon icon={Link04Icon} className="scale-110" />}
isActive={rippleEditingEnabled}
tooltip="Ripple editing"
onClick={() => toggleRippleEditing()}
/>
@@ -299,26 +222,26 @@ function ToolbarRightSection({
<Button
variant="text"
size="icon"
type="button"
onClick={() => onZoom({ direction: "out" })}
>
<HugeiconsIcon icon={SearchAddIcon} />
<HugeiconsIcon icon={SearchMinusIcon} />
</Button>
<Slider
className="w-24"
value={[zoomLevel]}
onValueChange={(values) => onZoomChange(values[0])}
min={minZoom}
max={TIMELINE_CONSTANTS.ZOOM_MAX}
step={TIMELINE_CONSTANTS.ZOOM_STEP}
className="w-28"
value={[zoomToSlider({ zoomLevel, minZoom })]}
onValueChange={(values) =>
onZoomChange(sliderToZoom({ sliderPosition: values[0], minZoom }))
}
min={0}
max={1}
step={0.005}
/>
<Button
variant="text"
size="icon"
type="button"
onClick={() => onZoom({ direction: "in" })}
>
<HugeiconsIcon icon={SearchMinusIcon} />
<HugeiconsIcon icon={SearchAddIcon} />
</Button>
</div>
</div>
@@ -330,21 +253,25 @@ function ToolbarButton({
tooltip,
onClick,
disabled,
isActive,
}: {
icon: React.ReactNode;
tooltip: string;
onClick: ({ event }: { event: React.MouseEvent }) => void;
disabled?: boolean;
isActive?: boolean;
}) {
return (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Button
variant="text"
variant={isActive ? "secondary" : "text"}
size="icon"
type="button"
onClick={(event) => onClick({ event })}
className={disabled ? "cursor-not-allowed opacity-50" : ""}
className={cn(
"rounded-sm",
disabled ? "cursor-not-allowed opacity-50" : "",
)}
>
{icon}
</Button>
@@ -9,7 +9,6 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import type { ElementDragState } from "@/types/timeline";
import { useEditor } from "@/hooks/use-editor";
import { cn } from "@/utils/ui";
interface TimelineTrackContentProps {
track: TimelineTrack;
@@ -31,6 +30,7 @@ interface TimelineTrackContentProps {
track: TimelineTrack;
}) => void;
onTrackMouseDown?: (event: React.MouseEvent) => void;
onTrackClick?: (event: React.MouseEvent) => void;
shouldIgnoreClick?: () => boolean;
}
@@ -46,6 +46,7 @@ export function TimelineTrackContent({
onElementMouseDown,
onElementClick,
onTrackMouseDown,
onTrackClick,
shouldIgnoreClick,
}: TimelineTrackContentProps) {
const editor = useEditor();
@@ -61,18 +62,16 @@ export function TimelineTrackContent({
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
});
const hasSelectedElements = track.elements.some((element) =>
isElementSelected({ trackId: track.id, elementId: element.id }),
);
return (
<button
className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")}
onClick={() => {
className="size-full"
onClick={(event) => {
if (shouldIgnoreClick?.()) return;
clearElementSelection();
onTrackClick?.(event);
}}
onMouseDown={(event) => {
event.preventDefault();
onTrackMouseDown?.(event);
}}
type="button"
@@ -1,111 +0,0 @@
import { useEditor } from "@/hooks/use-editor";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { getSnappedSeekTime } from "@/lib/time";
import { Bookmark02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
interface TimelineBookmarksRowProps {
zoomLevel: number;
dynamicTimelineWidth: number;
handleWheel: (e: React.WheelEvent) => void;
handleTimelineContentClick: (e: React.MouseEvent) => void;
handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
handleRulerMouseDown: (e: React.MouseEvent) => void;
}
export function TimelineBookmarksRow({
zoomLevel,
dynamicTimelineWidth,
handleWheel,
handleTimelineContentClick,
handleRulerTrackingMouseDown,
handleRulerMouseDown,
}: TimelineBookmarksRowProps) {
const editor = useEditor();
const activeScene = editor.scenes.getActiveScene();
return (
<div className="relative h-4 flex-1 overflow-hidden">
<button
className="relative h-4 w-full cursor-default select-none border-0 bg-transparent p-0"
style={{
width: `${dynamicTimelineWidth}px`,
}}
aria-label="Timeline ruler"
type="button"
onWheel={handleWheel}
onClick={handleTimelineContentClick}
onMouseDown={(event) => {
handleRulerMouseDown(event);
handleRulerTrackingMouseDown(event);
}}
>
{activeScene.bookmarks.map((time: number) => (
<TimelineBookmark
key={`bookmark-row-${time}`}
time={time}
zoomLevel={zoomLevel}
/>
))}
</button>
</div>
);
}
export function TimelineBookmark({
time,
zoomLevel,
}: {
time: number;
zoomLevel: number;
}) {
const editor = useEditor();
const activeProject = editor.project.getActive();
const duration = editor.timeline.getTotalDuration();
const handleBookmarkActivate = ({
event,
}: {
event:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>;
}) => {
event.stopPropagation();
const framesPerSecond = activeProject?.settings.fps ?? 30;
const snappedTime = getSnappedSeekTime({
rawTime: time,
duration,
fps: framesPerSecond,
});
editor.playback.seek({ time: snappedTime });
};
return (
<button
className="absolute top-0 h-10 w-0.5 cursor-pointer border-0 bg-transparent p-0"
style={{
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
}}
aria-label={`Seek to bookmark at ${time}s`}
type="button"
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => handleBookmarkActivate({ event })}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
handleBookmarkActivate({ event });
}}
>
<div className="text-primary absolute top-[-1px] left-[-5px]">
<HugeiconsIcon
icon={Bookmark02Icon}
aria-hidden="true"
className="fill-primary size-3"
/>
</div>
</button>
);
}
@@ -1,80 +0,0 @@
"use client";
import { useFilmstrip } from "@/hooks/timeline/use-filmstrip";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { getThumbnailWidth } from "@/lib/timeline/filmstrip-utils";
interface FilmstripProps {
mediaId: string;
file: File | null;
duration: number;
trimStart: number;
trackHeight: number;
zoomLevel: number;
fallbackThumbnailUrl?: string;
}
export function Filmstrip({
mediaId,
file,
duration,
trimStart,
trackHeight,
zoomLevel,
fallbackThumbnailUrl,
}: FilmstripProps) {
const thumbnailWidth = getThumbnailWidth({ trackHeight });
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const visibleStartTime = trimStart;
const visibleEndTime = trimStart + duration;
const { frames } = useFilmstrip({
mediaId,
file,
duration,
visibleStartTime,
visibleEndTime,
zoomLevel,
});
if (frames.length === 0 && fallbackThumbnailUrl) {
return (
<div className="absolute inset-0 flex items-center justify-center">
<div
className="absolute right-0 left-0"
style={{
backgroundImage: `url(${fallbackThumbnailUrl})`,
backgroundRepeat: "repeat-x",
backgroundSize: `${thumbnailWidth}px ${trackHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
top: 0,
bottom: 0,
}}
/>
</div>
);
}
return (
<div className="absolute inset-0 overflow-hidden">
{frames.map((frame) => {
const left = (frame.timestamp - visibleStartTime) * pixelsPerSecond;
return (
<div
key={`${frame.mediaId}-${frame.timestamp}-${frame.tier}`}
className="absolute top-0 h-full bg-cover bg-center"
style={{
left: `${left}px`,
width: `${thumbnailWidth}px`,
backgroundImage: `url(${frame.dataUrl})`,
pointerEvents: "none",
}}
/>
);
})}
</div>
);
}
@@ -1,10 +1,7 @@
import { Button } from "./ui/button";
import Link from "next/link";
import { SOCIAL_LINKS } from "@/constants/site-constants";
import {
GithubIcon,
Link04Icon,
} from "@hugeicons/core-free-icons";
import { GithubIcon, Link04Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
export function GitHubContributeSection({
@@ -9,6 +9,7 @@ import {
useKeybindingDisabler,
} from "@/hooks/use-keybindings";
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
import { prefetchFontAtlas } from "@/lib/fonts/google-fonts";
interface EditorProviderProps {
projectId: string;
@@ -42,6 +43,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
if (cancelled) return;
setIsLoading(false);
prefetchFontAtlas();
} catch (err) {
if (cancelled) return;
+39 -36
View File
@@ -1,36 +1,39 @@
"use client";
import { Button } from "./ui/button";
import { useTheme } from "next-themes";
import { cn } from "@/utils/ui";
import { Sun03Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
interface ThemeToggleProps {
className?: string;
iconClassName?: string;
onToggle?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}
export function ThemeToggle({
className,
iconClassName,
onToggle,
}: ThemeToggleProps) {
const { theme, setTheme } = useTheme();
return (
<Button
size="icon"
variant="text"
className={cn("h-7", className)}
onClick={(e) => {
setTheme(theme === "dark" ? "light" : "dark");
onToggle?.(e);
}}
>
<HugeiconsIcon icon={Sun03Icon} className={cn("!size-[1.1rem]", iconClassName)} />
<span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span>
</Button>
);
}
"use client";
import { Button } from "./ui/button";
import { useTheme } from "next-themes";
import { cn } from "@/utils/ui";
import { Sun03Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
interface ThemeToggleProps {
className?: string;
iconClassName?: string;
onToggle?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}
export function ThemeToggle({
className,
iconClassName,
onToggle,
}: ThemeToggleProps) {
const { theme, setTheme } = useTheme();
return (
<Button
size="icon"
variant="ghost"
className={cn("size-8", className)}
onClick={(e) => {
setTheme(theme === "dark" ? "light" : "dark");
onToggle?.(e);
}}
>
<HugeiconsIcon
icon={Sun03Icon}
className={cn("!size-[1.1rem]", iconClassName)}
/>
<span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span>
</Button>
);
}
+8 -3
View File
@@ -1,6 +1,5 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { AlertTriangle } from "lucide-react";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { Alert02Icon } from "@hugeicons/core-free-icons";
@@ -32,7 +31,10 @@ const Alert = React.forwardRef<
{...props}
>
{variant === "destructive" && (
<HugeiconsIcon icon={Alert02Icon} className="size-5 text-destructive mt-0.5" />
<HugeiconsIcon
icon={Alert02Icon}
className="size-5 text-destructive mt-0.5"
/>
)}
{children}
</div>
@@ -45,7 +47,10 @@ const AlertTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-2 text-base leading-none font-semibold tracking-tight", className)}
className={cn(
"mb-2 text-base leading-none font-semibold tracking-tight",
className,
)}
{...props}
/>
));
+4 -4
View File
@@ -38,10 +38,10 @@ const AvatarFallback = React.forwardRef<
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className,
)}
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className,
)}
{...props}
/>
));

Some files were not shown because too many files have changed in this diff Show More