feat: transcription diagnostics
This commit is contained in:
parent
f726d95295
commit
91e1232112
|
|
@ -1,284 +1,291 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/* Custom variant for dark mode */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Plugins */
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
:root {
|
||||
--background: hsl(0, 0%, 100%);
|
||||
--foreground: hsl(0 0% 11%);
|
||||
--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(200, 90%, 52%);
|
||||
--primary-foreground: hsl(0, 0%, 100%);
|
||||
--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(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% 91%);
|
||||
--input: hsl(0, 0%, 100%);
|
||||
--ring: hsl(0, 0%, 55%);
|
||||
--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% 96.1%);
|
||||
--sidebar-foreground: hsl(0 0% 2%);
|
||||
--sidebar-primary: hsl(0 0% 2%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 91%);
|
||||
--sidebar-accent: hsl(0 0% 85.1%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 2%);
|
||||
--sidebar-border: hsl(0 0% 85.1%);
|
||||
--sidebar-ring: hsl(0 0% 16.9%);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
}
|
||||
|
||||
.panel {
|
||||
--background: hsl(210, 20%, 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-foreground: hsl(0, 0%, 98%);
|
||||
--constructive-foreground: hsl(0, 0%, 98%);
|
||||
--border: hsl(0 0% 87%);
|
||||
--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%, 13%);
|
||||
--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%);
|
||||
--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%);
|
||||
--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, 67%, 9%);
|
||||
--secondary-border: hsl(204, 100%, 14%);
|
||||
--secondary-foreground: hsl(200, 98%, 63%);
|
||||
--muted: hsl(0 0% 22%);
|
||||
--accent: hsl(0, 0%, 15%);
|
||||
--accent-foreground: hsl(0 0% 93%);
|
||||
--border: hsl(0 0% 18%);
|
||||
--input: hsl(0 0% 22%);
|
||||
--ring: hsl(0, 0%, 52%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
/* Other default base styles */
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Prevent back/forward swipe */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
::selection {
|
||||
@apply bg-primary/35 selection:text-primary-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Responsive breakpoints */
|
||||
--breakpoint-xs: 30rem;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: var(--font-inter), sans-serif;
|
||||
|
||||
/* Font sizes */
|
||||
--text-xs: 0.72rem;
|
||||
--text-sm: 0.79rem;
|
||||
--text-base: 0.92rem;
|
||||
--text-base--line-height: calc(1.5 / 0.95);
|
||||
--text-xs--line-height: calc(1 / 0.8);
|
||||
|
||||
/* Border radius */
|
||||
--radius-lg: 0.82rem;
|
||||
--radius-md: 0.65rem;
|
||||
--radius-sm: 0.35rem;
|
||||
|
||||
/* Palette mapped to root design tokens */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-card: var(--card);
|
||||
--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-secondary: var(--secondary);
|
||||
--color-secondary-border: var(--secondary-border);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-constructive: var(--constructive);
|
||||
--color-constructive-foreground: var(--constructive-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
/* Chart colors */
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
/* Sidebar */
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
/* Animations */
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-x-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:horizontal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-y-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:vertical {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-thin {
|
||||
&::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Custom variant for dark mode */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Plugins */
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
:root {
|
||||
--background: hsl(0, 0%, 100%);
|
||||
--foreground: hsl(0 0% 11%);
|
||||
--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(200, 90%, 52%);
|
||||
--primary-foreground: hsl(0, 0%, 100%);
|
||||
--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(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%);
|
||||
--caution: hsl(38, 92%, 50%);
|
||||
--caution-foreground: hsl(0, 0%, 10%);
|
||||
--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%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(0 0% 96.1%);
|
||||
--sidebar-foreground: hsl(0 0% 2%);
|
||||
--sidebar-primary: hsl(0 0% 2%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 91%);
|
||||
--sidebar-accent: hsl(0 0% 85.1%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 2%);
|
||||
--sidebar-border: hsl(0 0% 85.1%);
|
||||
--sidebar-ring: hsl(0 0% 16.9%);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
}
|
||||
|
||||
.panel {
|
||||
--background: hsl(210, 20%, 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-foreground: hsl(0, 0%, 98%);
|
||||
--constructive-foreground: hsl(0, 0%, 98%);
|
||||
--border: hsl(0 0% 87%);
|
||||
--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%, 13%);
|
||||
--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%);
|
||||
--border: hsl(0 0% 16%);
|
||||
--input: hsl(0 0% 5%);
|
||||
--ring: hsl(0, 0%, 50%);
|
||||
--caution: hsl(38, 92%, 60%);
|
||||
--caution-foreground: hsl(0, 0%, 10%);
|
||||
--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%);
|
||||
--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, 67%, 9%);
|
||||
--secondary-border: hsl(204, 100%, 14%);
|
||||
--secondary-foreground: hsl(200, 98%, 63%);
|
||||
--muted: hsl(0 0% 22%);
|
||||
--accent: hsl(0, 0%, 15%);
|
||||
--accent-foreground: hsl(0 0% 93%);
|
||||
--border: hsl(0 0% 18%);
|
||||
--input: hsl(0 0% 22%);
|
||||
--ring: hsl(0, 0%, 52%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
/* Other default base styles */
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Prevent back/forward swipe */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
::selection {
|
||||
@apply bg-primary/35 selection:text-primary-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Responsive breakpoints */
|
||||
--breakpoint-xs: 30rem;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: var(--font-inter), sans-serif;
|
||||
|
||||
/* Font sizes */
|
||||
--text-xs: 0.72rem;
|
||||
--text-sm: 0.79rem;
|
||||
--text-base: 0.92rem;
|
||||
--text-base--line-height: calc(1.5 / 0.95);
|
||||
--text-xs--line-height: calc(1 / 0.8);
|
||||
|
||||
/* Border radius */
|
||||
--radius-lg: 0.82rem;
|
||||
--radius-md: 0.65rem;
|
||||
--radius-sm: 0.35rem;
|
||||
|
||||
/* Palette mapped to root design tokens */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-card: var(--card);
|
||||
--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-secondary: var(--secondary);
|
||||
--color-secondary-border: var(--secondary-border);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-constructive: var(--constructive);
|
||||
--color-constructive-foreground: var(--constructive-foreground);
|
||||
|
||||
--color-caution: var(--caution);
|
||||
--color-caution-foreground: var(--caution-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
/* Chart colors */
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
/* Sidebar */
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
/* Animations */
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-x-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:horizontal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-y-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:vertical {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-thin {
|
||||
&::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,8 @@ import {
|
|||
import { useReducer, useRef, useState } from "react";
|
||||
import { extractTimelineAudio } from "@/lib/media/mediabunny";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
|
||||
} from "@/lib/transcription/audio";
|
||||
import { TRANSCRIPTION_DIAGNOSTICS_SCOPE } from "@/lib/transcription/diagnostics";
|
||||
import { DEFAULT_TRANSCRIPTION_SAMPLE_RATE } from "@/lib/transcription/audio";
|
||||
import { TRANSCRIPTION_LANGUAGES } from "@/lib/transcription/supported-languages";
|
||||
import type {
|
||||
CaptionChunk,
|
||||
|
|
@ -31,8 +30,23 @@ import {
|
|||
SectionField,
|
||||
SectionFields,
|
||||
} from "@/components/section";
|
||||
import { CloudUploadIcon } from "@hugeicons/core-free-icons";
|
||||
import { AlertCircleIcon, CloudUploadIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { DiagnosticSeverity } from "@/lib/diagnostics/types";
|
||||
|
||||
const DIAGNOSTIC_BUTTON_VARIANT: Record<
|
||||
DiagnosticSeverity,
|
||||
"caution" | "destructive-foreground"
|
||||
> = {
|
||||
caution: "caution",
|
||||
error: "destructive-foreground",
|
||||
};
|
||||
|
||||
type ProcessingState =
|
||||
| { status: "idle"; error: string | null; warnings: string[] }
|
||||
|
|
@ -44,7 +58,11 @@ type ProcessingAction =
|
|||
| { type: "succeed"; warnings: string[] }
|
||||
| { type: "fail"; error: string };
|
||||
|
||||
const IDLE_STATE: ProcessingState = { status: "idle", error: null, warnings: [] };
|
||||
const IDLE_STATE: ProcessingState = {
|
||||
status: "idle",
|
||||
error: null,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
function processingReducer(
|
||||
state: ProcessingState,
|
||||
|
|
@ -73,6 +91,10 @@ export function Captions() {
|
|||
|
||||
const isProcessing = processing.status === "processing";
|
||||
|
||||
const activeDiagnostics = useEditor((e) =>
|
||||
e.diagnostics.getActive({ scope: TRANSCRIPTION_DIAGNOSTICS_SCOPE }),
|
||||
);
|
||||
|
||||
const handleProgress = (progress: TranscriptionProgress) => {
|
||||
if (progress.status === "loading-model") {
|
||||
dispatch({
|
||||
|
|
@ -84,7 +106,11 @@ export function Captions() {
|
|||
}
|
||||
};
|
||||
|
||||
const insertCaptions = ({ captions }: { captions: CaptionChunk[] }): boolean => {
|
||||
const insertCaptions = ({
|
||||
captions,
|
||||
}: {
|
||||
captions: CaptionChunk[];
|
||||
}): boolean => {
|
||||
const trackId = insertCaptionChunksAsTextTrack({ editor, captions });
|
||||
return trackId !== null;
|
||||
};
|
||||
|
|
@ -123,7 +149,10 @@ export function Captions() {
|
|||
console.error("Transcription failed:", error);
|
||||
dispatch({
|
||||
type: "fail",
|
||||
error: error instanceof Error ? error.message : "An unexpected error occurred",
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -168,7 +197,10 @@ export function Captions() {
|
|||
console.error("Subtitle import failed:", error);
|
||||
dispatch({
|
||||
type: "fail",
|
||||
error: error instanceof Error ? error.message : "An unexpected error occurred",
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -208,17 +240,38 @@ export function Captions() {
|
|||
title="Captions"
|
||||
contentClassName="px-0 flex flex-col h-full"
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleImportClick}
|
||||
disabled={isProcessing}
|
||||
className="items-center justify-center gap-1.5"
|
||||
>
|
||||
<HugeiconsIcon icon={CloudUploadIcon} />
|
||||
Import
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!isProcessing &&
|
||||
activeDiagnostics.map((diagnostic) => (
|
||||
<Tooltip key={diagnostic.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={DIAGNOSTIC_BUTTON_VARIANT[diagnostic.severity]}
|
||||
size="icon"
|
||||
aria-label={diagnostic.message}
|
||||
>
|
||||
<HugeiconsIcon icon={AlertCircleIcon} size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{diagnostic.message}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleImportClick}
|
||||
disabled={isProcessing}
|
||||
className="items-center justify-center gap-1.5"
|
||||
>
|
||||
<HugeiconsIcon icon={CloudUploadIcon} />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
}
|
||||
ref={containerRef}
|
||||
>
|
||||
|
|
@ -229,7 +282,11 @@ export function Captions() {
|
|||
className="hidden"
|
||||
onChange={(event) => void handleFileChange({ event })}
|
||||
/>
|
||||
<Section showTopBorder={false} showBottomBorder={false} className="flex-1">
|
||||
<Section
|
||||
showTopBorder={false}
|
||||
showBottomBorder={false}
|
||||
className="flex-1"
|
||||
>
|
||||
<SectionContent className="flex flex-col gap-4 h-full pt-1">
|
||||
<SectionFields>
|
||||
<SectionField label="Language">
|
||||
|
|
@ -256,7 +313,7 @@ export function Captions() {
|
|||
type="button"
|
||||
className="mt-auto w-full"
|
||||
onClick={handleGenerateTranscript}
|
||||
disabled={isProcessing}
|
||||
disabled={isProcessing || activeDiagnostics.length > 0}
|
||||
>
|
||||
{isProcessing && <Spinner className="mr-1" />}
|
||||
{isProcessing ? processing.step : "Generate transcript"}
|
||||
|
|
|
|||
|
|
@ -1,455 +1,455 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { resolveStickerIntrinsicSize } from "@/lib/stickers";
|
||||
import {
|
||||
buildGraphicElement,
|
||||
buildStickerElement,
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import { STICKER_CATEGORIES } from "@/lib/stickers/categories";
|
||||
import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers";
|
||||
import { parseShapeStickerId } from "@/lib/stickers/providers/shapes";
|
||||
import type { TimelineDragData } from "@/lib/timeline/drag";
|
||||
import type {
|
||||
StickerBrowseSection,
|
||||
StickerCategory,
|
||||
StickerItem as StickerData,
|
||||
} from "@/lib/stickers";
|
||||
import { useStickersStore } from "@/stores/stickers-store";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
HappyIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
||||
export function StickersView() {
|
||||
const {
|
||||
browseContent,
|
||||
browseStickers,
|
||||
searchQuery,
|
||||
searchStickers,
|
||||
selectedCategory,
|
||||
setSearchQuery,
|
||||
setSelectedCategory,
|
||||
viewMode,
|
||||
} = useStickersStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === "browse" && !browseContent) {
|
||||
void browseStickers();
|
||||
}
|
||||
}, [browseContent, browseStickers, viewMode]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col py-2">
|
||||
<div className="px-2">
|
||||
<Input
|
||||
size="sm"
|
||||
variant="default"
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery({ query: e.target.value });
|
||||
void searchStickers({ query: e.target.value });
|
||||
}}
|
||||
showClearIcon
|
||||
onClear={() => {
|
||||
setSearchQuery({ query: "" });
|
||||
void searchStickers({ query: "" });
|
||||
}}
|
||||
className="w-full"
|
||||
containerClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={selectedCategory}
|
||||
onValueChange={(value) => {
|
||||
setSelectedCategory({ category: value as StickerCategory });
|
||||
}}
|
||||
variant="underline"
|
||||
className="mt-2 flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<TabsList aria-label="Sticker categories">
|
||||
{Object.entries(STICKER_CATEGORIES).map(([key, label]) => (
|
||||
<TabsTrigger key={key} value={key}>
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4">
|
||||
<StickersContentView />
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StickerGrid({
|
||||
items,
|
||||
shouldCapSize = false,
|
||||
}: {
|
||||
items: StickerData[];
|
||||
shouldCapSize?: boolean;
|
||||
}) {
|
||||
const gridStyle: CSSProperties & {
|
||||
"--sticker-min": string;
|
||||
"--sticker-max"?: string;
|
||||
} = {
|
||||
gridTemplateColumns: shouldCapSize
|
||||
? "repeat(auto-fill, minmax(var(--sticker-min, 80px), var(--sticker-max, 140px)))"
|
||||
: "repeat(auto-fill, minmax(var(--sticker-min, 80px), 1fr))",
|
||||
"--sticker-min": "80px",
|
||||
...(shouldCapSize ? { "--sticker-max": "140px" } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-2" style={gridStyle}>
|
||||
{items.map((item) => (
|
||||
<StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StickerRow({ items }: { items: StickerData[] }) {
|
||||
return (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hidden">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="w-20 shrink-0">
|
||||
<StickerItem item={item} shouldCapSize containerClassName="w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyView({ message }: { message: string }) {
|
||||
return (
|
||||
<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"
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<p className="text-lg font-medium">No stickers found</p>
|
||||
<p className="text-muted-foreground text-sm text-balance">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionBanner({ region }: { region: string }) {
|
||||
return (
|
||||
<div className="flex h-7 items-center gap-1.5 rounded-lg border border-sky-100 bg-sky-50 px-2">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0 text-sky-600"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
<span className="text-xs font-semibold text-sky-600">{region}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StickersContentView() {
|
||||
const {
|
||||
browseContent,
|
||||
clearRecentStickers,
|
||||
isBrowsing,
|
||||
isSearching,
|
||||
searchQuery,
|
||||
searchResults,
|
||||
selectedCategory,
|
||||
setSelectedCategory,
|
||||
viewMode,
|
||||
} = useStickersStore();
|
||||
|
||||
if (viewMode === "search") {
|
||||
if (isSearching) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (searchResults?.items.length) {
|
||||
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||
const isRegionSearch =
|
||||
selectedCategory === "flags" &&
|
||||
resolveQueryToRegions({ query: normalizedQuery }) !== null;
|
||||
const regionLabel = getRegionLabel({ query: normalizedQuery });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 pb-4">
|
||||
{isRegionSearch && <RegionBanner region={regionLabel} />}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{searchResults.total} results
|
||||
</span>
|
||||
</div>
|
||||
<StickerGrid items={searchResults.items} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// "all" tab search — sections are in browseContent, fall through to section rendering below
|
||||
if (selectedCategory !== "all" && searchQuery) {
|
||||
return <EmptyView message={`No stickers found for "${searchQuery}"`} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (isBrowsing && !browseContent) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!browseContent?.sections.length) {
|
||||
const categoryLabel = STICKER_CATEGORIES[selectedCategory];
|
||||
return (
|
||||
<EmptyView
|
||||
message={
|
||||
viewMode === "search"
|
||||
? `No stickers found for "${searchQuery}"`
|
||||
: selectedCategory === "all"
|
||||
? "No stickers available yet."
|
||||
: `No stickers available in ${categoryLabel.toLowerCase()} yet.`
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 pb-4">
|
||||
{browseContent.sections.map((section) => (
|
||||
<StickerSection
|
||||
key={section.id}
|
||||
section={section}
|
||||
onClearRecent={clearRecentStickers}
|
||||
onSeeAll={(category) => {
|
||||
setSelectedCategory({ category });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StickerSection({
|
||||
section,
|
||||
onClearRecent,
|
||||
onSeeAll,
|
||||
}: {
|
||||
section: StickerBrowseSection;
|
||||
onClearRecent: () => void;
|
||||
onSeeAll: (category: StickerCategory) => void;
|
||||
}) {
|
||||
const hasHeader =
|
||||
Boolean(section.title) || section.id === "recent" || section.action;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{hasHeader && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{section.title ? (
|
||||
<p className="text-xs text-muted-foreground">{section.title}</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{section.id === "recent" && (
|
||||
<Button
|
||||
onClick={onClearRecent}
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="h-auto gap-1 p-0 text-xs text-muted-foreground"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{section.action?.type === "see-all" && section.action.category && (
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="h-auto gap-1 p-0 text-xs text-primary"
|
||||
onClick={() => {
|
||||
onSeeAll(section.action?.category as StickerCategory);
|
||||
}}
|
||||
>
|
||||
See all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section.layout === "row" ? (
|
||||
<StickerRow items={section.items} />
|
||||
) : (
|
||||
<StickerGrid items={section.items} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StickerItemProps {
|
||||
item: StickerData;
|
||||
shouldCapSize?: boolean;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
function StickerItem({
|
||||
item,
|
||||
shouldCapSize = false,
|
||||
containerClassName,
|
||||
}: StickerItemProps) {
|
||||
const editor = useEditor();
|
||||
const { addToRecentStickers } = useStickersStore();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [hasImageError, setHasImageError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!item.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHasImageError(false);
|
||||
}, [item.id]);
|
||||
|
||||
const displayName = item.name;
|
||||
const shapePreset =
|
||||
item.provider === "shapes" ? parseShapeStickerId({ stickerId: item.id }) : null;
|
||||
|
||||
const handleAdd = async () => {
|
||||
setIsAdding(true);
|
||||
try {
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
|
||||
let element:
|
||||
| ReturnType<typeof buildGraphicElement>
|
||||
| ReturnType<typeof buildStickerElement>;
|
||||
if (shapePreset) {
|
||||
element = buildGraphicElement({
|
||||
definitionId: shapePreset.definitionId,
|
||||
name: shapePreset.name,
|
||||
startTime: currentTime,
|
||||
params: shapePreset.params,
|
||||
});
|
||||
} else {
|
||||
const { width: intrinsicWidth, height: intrinsicHeight } =
|
||||
await resolveStickerIntrinsicSize({ stickerId: item.id });
|
||||
element = buildStickerElement({
|
||||
stickerId: item.id,
|
||||
name: item.name,
|
||||
startTime: currentTime,
|
||||
intrinsicWidth,
|
||||
intrinsicHeight,
|
||||
});
|
||||
}
|
||||
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "auto" },
|
||||
element,
|
||||
});
|
||||
|
||||
addToRecentStickers({ stickerId: item.id });
|
||||
} catch (error) {
|
||||
console.error("Failed to add sticker:", error);
|
||||
toast.error("Failed to add sticker to timeline");
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const preview = (
|
||||
<div className="flex size-full items-center justify-center p-3">
|
||||
{hasImageError ? (
|
||||
<span className="text-muted-foreground text-center text-xs break-all">
|
||||
{displayName}
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={item.previewUrl}
|
||||
alt={displayName}
|
||||
width={64}
|
||||
height={64}
|
||||
className="size-full object-contain"
|
||||
style={
|
||||
shouldCapSize
|
||||
? {
|
||||
maxWidth: "var(--sticker-max, 160px)",
|
||||
maxHeight: "var(--sticker-max, 160px)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onError={() => setHasImageError(true)}
|
||||
loading="lazy"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const dragData: TimelineDragData = shapePreset
|
||||
? {
|
||||
id: item.id,
|
||||
type: "graphic",
|
||||
name: displayName,
|
||||
definitionId: shapePreset.definitionId,
|
||||
params: shapePreset.params ?? {},
|
||||
}
|
||||
: {
|
||||
id: item.id,
|
||||
type: "sticker",
|
||||
name: displayName,
|
||||
stickerId: item.id,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative", isAdding && "pointer-events-none opacity-50")}
|
||||
>
|
||||
<DraggableItem
|
||||
name={displayName}
|
||||
preview={preview}
|
||||
dragData={dragData}
|
||||
onAddToTimeline={handleAdd}
|
||||
aspectRatio={1}
|
||||
shouldShowLabel={false}
|
||||
isRounded
|
||||
variant="card"
|
||||
containerClassName={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>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { resolveStickerIntrinsicSize } from "@/lib/stickers";
|
||||
import {
|
||||
buildGraphicElement,
|
||||
buildStickerElement,
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import { STICKER_CATEGORIES } from "@/lib/stickers/categories";
|
||||
import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers";
|
||||
import { parseShapeStickerId } from "@/lib/stickers/providers/shapes";
|
||||
import type { TimelineDragData } from "@/lib/timeline/drag";
|
||||
import type {
|
||||
StickerBrowseSection,
|
||||
StickerCategory,
|
||||
StickerItem as StickerData,
|
||||
} from "@/lib/stickers";
|
||||
import { useStickersStore } from "@/stores/stickers-store";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
HappyIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
||||
export function StickersView() {
|
||||
const {
|
||||
browseContent,
|
||||
browseStickers,
|
||||
searchQuery,
|
||||
searchStickers,
|
||||
selectedCategory,
|
||||
setSearchQuery,
|
||||
setSelectedCategory,
|
||||
viewMode,
|
||||
} = useStickersStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === "browse" && !browseContent) {
|
||||
void browseStickers();
|
||||
}
|
||||
}, [browseContent, browseStickers, viewMode]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col py-2">
|
||||
<div className="px-2">
|
||||
<Input
|
||||
size="sm"
|
||||
variant="default"
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery({ query: e.target.value });
|
||||
void searchStickers({ query: e.target.value });
|
||||
}}
|
||||
showClearIcon
|
||||
onClear={() => {
|
||||
setSearchQuery({ query: "" });
|
||||
void searchStickers({ query: "" });
|
||||
}}
|
||||
className="w-full"
|
||||
containerClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={selectedCategory}
|
||||
onValueChange={(value) => {
|
||||
setSelectedCategory({ category: value as StickerCategory });
|
||||
}}
|
||||
variant="underline"
|
||||
className="mt-2 flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<TabsList aria-label="Sticker categories">
|
||||
{Object.entries(STICKER_CATEGORIES).map(([key, label]) => (
|
||||
<TabsTrigger key={key} value={key}>
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4">
|
||||
<StickersContentView />
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StickerGrid({
|
||||
items,
|
||||
shouldCapSize = false,
|
||||
}: {
|
||||
items: StickerData[];
|
||||
shouldCapSize?: boolean;
|
||||
}) {
|
||||
const gridStyle: CSSProperties & {
|
||||
"--sticker-min": string;
|
||||
"--sticker-max"?: string;
|
||||
} = {
|
||||
gridTemplateColumns: shouldCapSize
|
||||
? "repeat(auto-fill, minmax(var(--sticker-min, 80px), var(--sticker-max, 140px)))"
|
||||
: "repeat(auto-fill, minmax(var(--sticker-min, 80px), 1fr))",
|
||||
"--sticker-min": "80px",
|
||||
...(shouldCapSize ? { "--sticker-max": "140px" } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-2" style={gridStyle}>
|
||||
{items.map((item) => (
|
||||
<StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StickerRow({ items }: { items: StickerData[] }) {
|
||||
return (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hidden">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="w-20 shrink-0">
|
||||
<StickerItem item={item} shouldCapSize containerClassName="w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyView({ message }: { message: string }) {
|
||||
return (
|
||||
<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"
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<p className="text-lg font-medium">No stickers found</p>
|
||||
<p className="text-muted-foreground text-sm text-balance">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionBanner({ region }: { region: string }) {
|
||||
return (
|
||||
<div className="flex h-7 items-center gap-1.5 rounded-lg border border-sky-100 bg-sky-50 px-2">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0 text-sky-600"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
<span className="text-xs font-semibold text-sky-600">{region}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StickersContentView() {
|
||||
const {
|
||||
browseContent,
|
||||
clearRecentStickers,
|
||||
isBrowsing,
|
||||
isSearching,
|
||||
searchQuery,
|
||||
searchResults,
|
||||
selectedCategory,
|
||||
setSelectedCategory,
|
||||
viewMode,
|
||||
} = useStickersStore();
|
||||
|
||||
if (viewMode === "search") {
|
||||
if (isSearching) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (searchResults?.items.length) {
|
||||
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||
const isRegionSearch =
|
||||
selectedCategory === "flags" &&
|
||||
resolveQueryToRegions({ query: normalizedQuery }) !== null;
|
||||
const regionLabel = getRegionLabel({ query: normalizedQuery });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 pb-4">
|
||||
{isRegionSearch && <RegionBanner region={regionLabel} />}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{searchResults.total} results
|
||||
</span>
|
||||
</div>
|
||||
<StickerGrid items={searchResults.items} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// "all" tab search — sections are in browseContent, fall through to section rendering below
|
||||
if (selectedCategory !== "all" && searchQuery) {
|
||||
return <EmptyView message={`No stickers found for "${searchQuery}"`} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (isBrowsing && !browseContent) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!browseContent?.sections.length) {
|
||||
const categoryLabel = STICKER_CATEGORIES[selectedCategory];
|
||||
return (
|
||||
<EmptyView
|
||||
message={
|
||||
viewMode === "search"
|
||||
? `No stickers found for "${searchQuery}"`
|
||||
: selectedCategory === "all"
|
||||
? "No stickers available yet."
|
||||
: `No stickers available in ${categoryLabel.toLowerCase()} yet.`
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 pb-4">
|
||||
{browseContent.sections.map((section) => (
|
||||
<StickerSection
|
||||
key={section.id}
|
||||
section={section}
|
||||
onClearRecent={clearRecentStickers}
|
||||
onSeeAll={(category) => {
|
||||
setSelectedCategory({ category });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StickerSection({
|
||||
section,
|
||||
onClearRecent,
|
||||
onSeeAll,
|
||||
}: {
|
||||
section: StickerBrowseSection;
|
||||
onClearRecent: () => void;
|
||||
onSeeAll: (category: StickerCategory) => void;
|
||||
}) {
|
||||
const hasHeader =
|
||||
Boolean(section.title) || section.id === "recent" || section.action;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{hasHeader && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{section.title ? (
|
||||
<p className="text-xs text-muted-foreground">{section.title}</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{section.id === "recent" && (
|
||||
<Button
|
||||
onClick={onClearRecent}
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="h-auto gap-1 p-0 text-xs text-muted-foreground"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{section.action?.type === "see-all" && section.action.category && (
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="h-auto gap-1 p-0 text-xs text-primary"
|
||||
onClick={() => {
|
||||
onSeeAll(section.action?.category as StickerCategory);
|
||||
}}
|
||||
>
|
||||
See all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section.layout === "row" ? (
|
||||
<StickerRow items={section.items} />
|
||||
) : (
|
||||
<StickerGrid items={section.items} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StickerItemProps {
|
||||
item: StickerData;
|
||||
shouldCapSize?: boolean;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
function StickerItem({
|
||||
item,
|
||||
shouldCapSize = false,
|
||||
containerClassName,
|
||||
}: StickerItemProps) {
|
||||
const editor = useEditor();
|
||||
const { addToRecentStickers } = useStickersStore();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [hasImageError, setHasImageError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!item.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHasImageError(false);
|
||||
}, [item.id]);
|
||||
|
||||
const displayName = item.name;
|
||||
const shapePreset =
|
||||
item.provider === "shapes" ? parseShapeStickerId({ stickerId: item.id }) : null;
|
||||
|
||||
const handleAdd = async () => {
|
||||
setIsAdding(true);
|
||||
try {
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
|
||||
let element:
|
||||
| ReturnType<typeof buildGraphicElement>
|
||||
| ReturnType<typeof buildStickerElement>;
|
||||
if (shapePreset) {
|
||||
element = buildGraphicElement({
|
||||
definitionId: shapePreset.definitionId,
|
||||
name: shapePreset.name,
|
||||
startTime: currentTime,
|
||||
params: shapePreset.params,
|
||||
});
|
||||
} else {
|
||||
const { width: intrinsicWidth, height: intrinsicHeight } =
|
||||
await resolveStickerIntrinsicSize({ stickerId: item.id });
|
||||
element = buildStickerElement({
|
||||
stickerId: item.id,
|
||||
name: item.name,
|
||||
startTime: currentTime,
|
||||
intrinsicWidth,
|
||||
intrinsicHeight,
|
||||
});
|
||||
}
|
||||
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "auto" },
|
||||
element,
|
||||
});
|
||||
|
||||
addToRecentStickers({ stickerId: item.id });
|
||||
} catch (error) {
|
||||
console.error("Failed to add sticker:", error);
|
||||
toast.error("Failed to add sticker to timeline");
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const preview = (
|
||||
<div className="flex size-full items-center justify-center p-3">
|
||||
{hasImageError ? (
|
||||
<span className="text-muted-foreground text-center text-xs break-all">
|
||||
{displayName}
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={item.previewUrl}
|
||||
alt={displayName}
|
||||
width={64}
|
||||
height={64}
|
||||
className="size-full object-contain"
|
||||
style={
|
||||
shouldCapSize
|
||||
? {
|
||||
maxWidth: "var(--sticker-max, 160px)",
|
||||
maxHeight: "var(--sticker-max, 160px)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onError={() => setHasImageError(true)}
|
||||
loading="lazy"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const dragData: TimelineDragData = shapePreset
|
||||
? {
|
||||
id: item.id,
|
||||
type: "graphic",
|
||||
name: displayName,
|
||||
definitionId: shapePreset.definitionId,
|
||||
params: shapePreset.params ?? {},
|
||||
}
|
||||
: {
|
||||
id: item.id,
|
||||
type: "sticker",
|
||||
name: displayName,
|
||||
stickerId: item.id,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative", isAdding && "pointer-events-none opacity-50")}
|
||||
>
|
||||
<DraggableItem
|
||||
name={displayName}
|
||||
preview={preview}
|
||||
dragData={dragData}
|
||||
onAddToTimeline={handleAdd}
|
||||
aspectRatio={1}
|
||||
shouldShowLabel={false}
|
||||
isRounded
|
||||
variant="card"
|
||||
containerClassName={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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const buttonVariants = cva(
|
|||
"bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
"destructive-foreground":
|
||||
"border bg-background hover:bg-destructive/15 text-destructive",
|
||||
caution: "text-caution hover:bg-caution/10",
|
||||
outline: "border border-border bg-background hover:bg-accent",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground border border-secondary-border",
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ import { SaveManager } from "./managers/save-manager";
|
|||
import { AudioManager } from "./managers/audio-manager";
|
||||
import { SelectionManager } from "./managers/selection-manager";
|
||||
import { ClipboardManager } from "./managers/clipboard-manager";
|
||||
import { DiagnosticsManager } from "./managers/diagnostics-manager";
|
||||
import { registerDefaultEffects } from "@/lib/effects";
|
||||
import { registerDefaultMasks } from "@/lib/masks";
|
||||
import { registerTranscriptionDiagnostics } from "@/lib/transcription/diagnostics";
|
||||
|
||||
export class EditorCore {
|
||||
private static instance: EditorCore | null = null;
|
||||
|
|
@ -25,6 +27,7 @@ export class EditorCore {
|
|||
public readonly audio: AudioManager;
|
||||
public readonly selection: SelectionManager;
|
||||
public readonly clipboard: ClipboardManager;
|
||||
public readonly diagnostics: DiagnosticsManager;
|
||||
|
||||
private constructor() {
|
||||
registerDefaultEffects();
|
||||
|
|
@ -40,6 +43,8 @@ export class EditorCore {
|
|||
this.audio = new AudioManager(this);
|
||||
this.selection = new SelectionManager(this);
|
||||
this.clipboard = new ClipboardManager(this);
|
||||
this.diagnostics = new DiagnosticsManager(this);
|
||||
registerTranscriptionDiagnostics({ diagnostics: this.diagnostics });
|
||||
this.playback.bindTimelineScope();
|
||||
this.command.registerReactor(() => {
|
||||
const activeScene = this.scenes.getActiveSceneOrNull();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import type { EditorCore } from "@/core";
|
||||
import type { DiagnosticDefinition } from "@/lib/diagnostics/types";
|
||||
|
||||
interface DiagnosticRegistration extends DiagnosticDefinition {
|
||||
check: (editor: EditorCore) => boolean;
|
||||
}
|
||||
|
||||
export class DiagnosticsManager {
|
||||
private readonly registrations: DiagnosticRegistration[] = [];
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
||||
register(registration: DiagnosticRegistration): void {
|
||||
this.registrations.push(registration);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
getActive(options?: { scope?: string }): ReadonlyArray<DiagnosticDefinition> {
|
||||
const candidates =
|
||||
options?.scope !== undefined
|
||||
? this.registrations.filter((r) => r.scope === options.scope)
|
||||
: this.registrations;
|
||||
|
||||
return candidates.filter((r) => r.check(this.editor));
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
notify(): void {
|
||||
this.listeners.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ export function useEditor<T>(
|
|||
editor.renderer.subscribe(onChange),
|
||||
editor.selection.subscribe(onChange),
|
||||
editor.clipboard.subscribe(onChange),
|
||||
editor.diagnostics.subscribe(onChange),
|
||||
];
|
||||
return () => {
|
||||
unsubscribers.forEach((unsubscribe) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
export type DiagnosticSeverity = "caution" | "error";
|
||||
|
||||
export interface DiagnosticDefinition {
|
||||
id: string;
|
||||
scope: string;
|
||||
severity: DiagnosticSeverity;
|
||||
message: string;
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type {
|
||||
AudioElement,
|
||||
VideoElement,
|
||||
LibraryAudioElement,
|
||||
RetimeConfig,
|
||||
SceneTracks,
|
||||
|
|
@ -12,9 +13,7 @@ import {
|
|||
hasAnimatedVolume,
|
||||
resolveEffectiveAudioGain,
|
||||
} from "@/lib/timeline/audio-state";
|
||||
import {
|
||||
doesElementHaveEnabledAudio,
|
||||
} from "@/lib/timeline/audio-separation";
|
||||
import { doesElementHaveEnabledAudio } from "@/lib/timeline/audio-separation";
|
||||
import { canElementHaveAudio, hasMediaId } from "@/lib/timeline/element-utils";
|
||||
import { canTrackHaveAudio } from "@/lib/timeline";
|
||||
import { mediaSupportsAudio } from "@/lib/media/media-utils";
|
||||
|
|
@ -83,22 +82,23 @@ export async function decodeAudioToFloat32({
|
|||
return { samples, sampleRate: audioBuffer.sampleRate };
|
||||
}
|
||||
|
||||
export async function collectAudioElements({
|
||||
export interface AudibleElementCandidate {
|
||||
element: AudioElement | VideoElement;
|
||||
mediaAsset: MediaAsset | null;
|
||||
}
|
||||
|
||||
export function collectAudibleCandidates({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
audioContext,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
mediaAssets: MediaAsset[];
|
||||
audioContext: AudioContext;
|
||||
}): Promise<CollectedAudioElement[]> {
|
||||
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
||||
const mediaMap = new Map<string, MediaAsset>(
|
||||
mediaAssets.map((media) => [media.id, media]),
|
||||
);
|
||||
const pendingElements: Array<Promise<CollectedAudioElement | null>> = [];
|
||||
}): AudibleElementCandidate[] {
|
||||
const allTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
||||
const mediaMap = new Map(mediaAssets.map((a) => [a.id, a]));
|
||||
const candidates: AudibleElementCandidate[] = [];
|
||||
|
||||
for (const track of orderedTracks) {
|
||||
for (const track of allTracks) {
|
||||
if (canTrackHaveAudio(track) && track.muted) continue;
|
||||
|
||||
for (const element of track.elements) {
|
||||
|
|
@ -110,65 +110,95 @@ export async function collectAudioElements({
|
|||
: null;
|
||||
if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
|
||||
|
||||
const isTrackMuted = canTrackHaveAudio(track) && track.muted;
|
||||
candidates.push({ element, mediaAsset });
|
||||
}
|
||||
}
|
||||
|
||||
if (element.type === "audio") {
|
||||
pendingElements.push(
|
||||
resolveAudioBufferForElement({
|
||||
element,
|
||||
mediaMap,
|
||||
audioContext,
|
||||
}).then((audioBuffer) => {
|
||||
if (!audioBuffer) return null;
|
||||
const muted = element.muted === true || isTrackMuted;
|
||||
return {
|
||||
timelineElement: element,
|
||||
buffer: audioBuffer,
|
||||
startTime: element.startTime / TICKS_PER_SECOND,
|
||||
duration: element.duration / TICKS_PER_SECOND,
|
||||
trimStart: element.trimStart / TICKS_PER_SECOND,
|
||||
trimEnd: element.trimEnd / TICKS_PER_SECOND,
|
||||
volume: resolveEffectiveAudioGain({
|
||||
element,
|
||||
trackMuted: isTrackMuted,
|
||||
localTime: 0,
|
||||
}),
|
||||
muted,
|
||||
retime: element.retime,
|
||||
};
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
if (element.type === "video") {
|
||||
if (!mediaAsset || !mediaSupportsAudio({ media: mediaAsset })) continue;
|
||||
export function timelineHasAudio({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
mediaAssets: MediaAsset[];
|
||||
}): boolean {
|
||||
return collectAudibleCandidates({ tracks, mediaAssets }).some(
|
||||
({ element }) => element.muted !== true,
|
||||
);
|
||||
}
|
||||
|
||||
pendingElements.push(
|
||||
resolveAudioBufferForVideoElement({
|
||||
mediaAsset,
|
||||
audioContext,
|
||||
}).then((audioBuffer) => {
|
||||
if (!audioBuffer) return null;
|
||||
const muted = (element.muted ?? false) || isTrackMuted;
|
||||
return {
|
||||
timelineElement: element,
|
||||
buffer: audioBuffer,
|
||||
startTime: element.startTime / TICKS_PER_SECOND,
|
||||
duration: element.duration / TICKS_PER_SECOND,
|
||||
trimStart: element.trimStart / TICKS_PER_SECOND,
|
||||
trimEnd: element.trimEnd / TICKS_PER_SECOND,
|
||||
volume: resolveEffectiveAudioGain({
|
||||
element,
|
||||
trackMuted: isTrackMuted,
|
||||
localTime: 0,
|
||||
}),
|
||||
muted,
|
||||
retime: element.retime,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
export async function collectAudioElements({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
audioContext,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
mediaAssets: MediaAsset[];
|
||||
audioContext: AudioContext;
|
||||
}): Promise<CollectedAudioElement[]> {
|
||||
const candidates = collectAudibleCandidates({ tracks, mediaAssets });
|
||||
const mediaMap = new Map<string, MediaAsset>(
|
||||
mediaAssets.map((media) => [media.id, media]),
|
||||
);
|
||||
const pendingElements: Array<Promise<CollectedAudioElement | null>> = [];
|
||||
|
||||
for (const { element, mediaAsset } of candidates) {
|
||||
if (element.type === "audio") {
|
||||
pendingElements.push(
|
||||
resolveAudioBufferForElement({
|
||||
element,
|
||||
mediaMap,
|
||||
audioContext,
|
||||
}).then((audioBuffer) => {
|
||||
if (!audioBuffer) return null;
|
||||
return {
|
||||
timelineElement: element,
|
||||
buffer: audioBuffer,
|
||||
startTime: element.startTime / TICKS_PER_SECOND,
|
||||
duration: element.duration / TICKS_PER_SECOND,
|
||||
trimStart: element.trimStart / TICKS_PER_SECOND,
|
||||
trimEnd: element.trimEnd / TICKS_PER_SECOND,
|
||||
volume: resolveEffectiveAudioGain({
|
||||
element,
|
||||
trackMuted: false,
|
||||
localTime: 0,
|
||||
}),
|
||||
muted: element.muted === true,
|
||||
retime: element.retime,
|
||||
};
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (element.type === "video") {
|
||||
if (!mediaAsset || !mediaSupportsAudio({ media: mediaAsset })) continue;
|
||||
|
||||
pendingElements.push(
|
||||
resolveAudioBufferForVideoElement({
|
||||
mediaAsset,
|
||||
audioContext,
|
||||
}).then((audioBuffer) => {
|
||||
if (!audioBuffer) return null;
|
||||
return {
|
||||
timelineElement: element,
|
||||
buffer: audioBuffer,
|
||||
startTime: element.startTime / TICKS_PER_SECOND,
|
||||
duration: element.duration / TICKS_PER_SECOND,
|
||||
trimStart: element.trimStart / TICKS_PER_SECOND,
|
||||
trimEnd: element.trimEnd / TICKS_PER_SECOND,
|
||||
volume: resolveEffectiveAudioGain({
|
||||
element,
|
||||
trackMuted: false,
|
||||
localTime: 0,
|
||||
}),
|
||||
muted: element.muted ?? false,
|
||||
retime: element.retime,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -339,16 +369,16 @@ async function fetchLibraryAudioSource({
|
|||
type: "audio/mpeg",
|
||||
});
|
||||
|
||||
return {
|
||||
timelineElement: element,
|
||||
file,
|
||||
startTime: element.startTime / TICKS_PER_SECOND,
|
||||
duration: element.duration / TICKS_PER_SECOND,
|
||||
trimStart: element.trimStart / TICKS_PER_SECOND,
|
||||
trimEnd: element.trimEnd / TICKS_PER_SECOND,
|
||||
volume,
|
||||
retime: element.retime,
|
||||
};
|
||||
return {
|
||||
timelineElement: element,
|
||||
file,
|
||||
startTime: element.startTime / TICKS_PER_SECOND,
|
||||
duration: element.duration / TICKS_PER_SECOND,
|
||||
trimStart: element.trimStart / TICKS_PER_SECOND,
|
||||
trimEnd: element.trimEnd / TICKS_PER_SECOND,
|
||||
volume,
|
||||
retime: element.retime,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch library audio:", error);
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,158 +1,168 @@
|
|||
import { Input, ALL_FORMATS, BlobSource } from "mediabunny";
|
||||
import { createTimelineAudioBuffer } from "@/lib/media/audio";
|
||||
import type { SceneTracks } from "@/lib/timeline";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
|
||||
export async function getVideoInfo({
|
||||
videoFile,
|
||||
}: {
|
||||
videoFile: File;
|
||||
}): Promise<{
|
||||
duration: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
hasAudio: boolean;
|
||||
}> {
|
||||
const input = new Input({
|
||||
source: new BlobSource(videoFile),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const duration = await input.computeDuration();
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
|
||||
if (!videoTrack) {
|
||||
throw new Error("No video track found in the file");
|
||||
}
|
||||
|
||||
const packetStats = await videoTrack.computePacketStats(100);
|
||||
const fps = packetStats.averagePacketRate;
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
|
||||
return {
|
||||
duration,
|
||||
width: videoTrack.displayWidth,
|
||||
height: videoTrack.displayHeight,
|
||||
fps,
|
||||
hasAudio: audioTrack !== null,
|
||||
};
|
||||
}
|
||||
|
||||
const SAMPLE_RATE = 44100;
|
||||
const NUM_CHANNELS = 2;
|
||||
|
||||
export const extractTimelineAudio = async ({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
totalDuration,
|
||||
onProgress,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
mediaAssets: MediaAsset[];
|
||||
totalDuration: number;
|
||||
onProgress?: (progress: number) => void;
|
||||
}): Promise<Blob> => {
|
||||
if (totalDuration === 0) {
|
||||
return createWavBlob({ samples: new Float32Array(SAMPLE_RATE * 0.1) });
|
||||
}
|
||||
|
||||
onProgress?.(10);
|
||||
|
||||
const audioBuffer = await createTimelineAudioBuffer({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
duration: totalDuration,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
});
|
||||
|
||||
if (!audioBuffer) {
|
||||
const silentDuration = Math.max(1, totalDuration);
|
||||
const silentSamples = new Float32Array(
|
||||
Math.ceil(silentDuration * SAMPLE_RATE) * NUM_CHANNELS,
|
||||
);
|
||||
return createWavBlob({ samples: silentSamples });
|
||||
}
|
||||
|
||||
onProgress?.(90);
|
||||
|
||||
const interleavedSamples = interleaveAudioBuffer({ audioBuffer });
|
||||
onProgress?.(100);
|
||||
|
||||
return createWavBlob({ samples: interleavedSamples });
|
||||
};
|
||||
|
||||
function interleaveAudioBuffer({
|
||||
audioBuffer,
|
||||
}: {
|
||||
audioBuffer: AudioBuffer;
|
||||
}): Float32Array {
|
||||
const numChannels = Math.min(NUM_CHANNELS, audioBuffer.numberOfChannels);
|
||||
const interleavedSamples = new Float32Array(
|
||||
audioBuffer.length * NUM_CHANNELS,
|
||||
);
|
||||
|
||||
for (let sampleIndex = 0; sampleIndex < audioBuffer.length; sampleIndex++) {
|
||||
for (let channel = 0; channel < NUM_CHANNELS; channel++) {
|
||||
const sourceChannel = Math.min(channel, Math.max(0, numChannels - 1));
|
||||
interleavedSamples[sampleIndex * NUM_CHANNELS + channel] =
|
||||
audioBuffer.getChannelData(sourceChannel)[sampleIndex] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
return interleavedSamples;
|
||||
}
|
||||
|
||||
function createWavBlob({ samples }: { samples: Float32Array }): Blob {
|
||||
const numChannels = NUM_CHANNELS;
|
||||
const bitsPerSample = 16;
|
||||
const bytesPerSample = bitsPerSample / 8;
|
||||
const numSamples = samples.length / numChannels;
|
||||
const dataSize = numSamples * numChannels * bytesPerSample;
|
||||
const buffer = new ArrayBuffer(44 + dataSize);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
// riff header
|
||||
writeString({ view, offset: 0, str: "RIFF" });
|
||||
view.setUint32(4, 36 + dataSize, true);
|
||||
writeString({ view, offset: 8, str: "WAVE" });
|
||||
|
||||
// fmt chunk
|
||||
writeString({ view, offset: 12, str: "fmt " });
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, numChannels, true);
|
||||
view.setUint32(24, SAMPLE_RATE, true);
|
||||
view.setUint32(28, SAMPLE_RATE * numChannels * bytesPerSample, true);
|
||||
view.setUint16(32, numChannels * bytesPerSample, true);
|
||||
view.setUint16(34, bitsPerSample, true);
|
||||
|
||||
// data chunk
|
||||
writeString({ view, offset: 36, str: "data" });
|
||||
view.setUint32(40, dataSize, true);
|
||||
|
||||
// convert float32 to int16 and write
|
||||
let offset = 44;
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const sample = Math.max(-1, Math.min(1, samples[i]));
|
||||
const int16 = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
||||
view.setInt16(offset, int16, true);
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
return new Blob([buffer], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
function writeString({
|
||||
view,
|
||||
offset,
|
||||
str,
|
||||
}: {
|
||||
view: DataView;
|
||||
offset: number;
|
||||
str: string;
|
||||
}): void {
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
view.setUint8(offset + i, str.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
import { Input, ALL_FORMATS, BlobSource } from "mediabunny";
|
||||
import { createTimelineAudioBuffer } from "@/lib/media/audio";
|
||||
import type { SceneTracks } from "@/lib/timeline";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||
|
||||
export async function getVideoInfo({
|
||||
videoFile,
|
||||
}: {
|
||||
videoFile: File;
|
||||
}): Promise<{
|
||||
duration: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
hasAudio: boolean;
|
||||
}> {
|
||||
const input = new Input({
|
||||
source: new BlobSource(videoFile),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const duration = await input.computeDuration();
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
|
||||
if (!videoTrack) {
|
||||
throw new Error("No video track found in the file");
|
||||
}
|
||||
|
||||
const packetStats = await videoTrack.computePacketStats(100);
|
||||
const fps = packetStats.averagePacketRate;
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
|
||||
return {
|
||||
duration,
|
||||
width: videoTrack.displayWidth,
|
||||
height: videoTrack.displayHeight,
|
||||
fps,
|
||||
hasAudio: audioTrack !== null,
|
||||
};
|
||||
}
|
||||
|
||||
const SAMPLE_RATE = 44100;
|
||||
const NUM_CHANNELS = 2;
|
||||
const EMPTY_TIMELINE_SILENT_DURATION_SECONDS = 0.1;
|
||||
const MIN_SILENT_DURATION_SECONDS = 0.001;
|
||||
|
||||
export const extractTimelineAudio = async ({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
totalDuration,
|
||||
onProgress,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
mediaAssets: MediaAsset[];
|
||||
totalDuration: number;
|
||||
onProgress?: (progress: number) => void;
|
||||
}): Promise<Blob> => {
|
||||
if (totalDuration === 0) {
|
||||
return createWavBlob({
|
||||
samples: new Float32Array(
|
||||
SAMPLE_RATE * EMPTY_TIMELINE_SILENT_DURATION_SECONDS,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
onProgress?.(10);
|
||||
|
||||
const audioBuffer = await createTimelineAudioBuffer({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
duration: totalDuration,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
});
|
||||
|
||||
if (!audioBuffer) {
|
||||
const silentDurationSeconds = Math.max(
|
||||
MIN_SILENT_DURATION_SECONDS,
|
||||
totalDuration / TICKS_PER_SECOND,
|
||||
);
|
||||
const silentSamples = new Float32Array(
|
||||
Math.ceil(silentDurationSeconds * SAMPLE_RATE) * NUM_CHANNELS,
|
||||
);
|
||||
return createWavBlob({ samples: silentSamples });
|
||||
}
|
||||
|
||||
onProgress?.(90);
|
||||
|
||||
const interleavedSamples = interleaveAudioBuffer({ audioBuffer });
|
||||
onProgress?.(100);
|
||||
|
||||
return createWavBlob({ samples: interleavedSamples });
|
||||
};
|
||||
|
||||
function interleaveAudioBuffer({
|
||||
audioBuffer,
|
||||
}: {
|
||||
audioBuffer: AudioBuffer;
|
||||
}): Float32Array {
|
||||
const numChannels = Math.min(NUM_CHANNELS, audioBuffer.numberOfChannels);
|
||||
const interleavedSamples = new Float32Array(
|
||||
audioBuffer.length * NUM_CHANNELS,
|
||||
);
|
||||
|
||||
for (let sampleIndex = 0; sampleIndex < audioBuffer.length; sampleIndex++) {
|
||||
for (let channel = 0; channel < NUM_CHANNELS; channel++) {
|
||||
const sourceChannel = Math.min(channel, Math.max(0, numChannels - 1));
|
||||
interleavedSamples[sampleIndex * NUM_CHANNELS + channel] =
|
||||
audioBuffer.getChannelData(sourceChannel)[sampleIndex] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
return interleavedSamples;
|
||||
}
|
||||
|
||||
function createWavBlob({ samples }: { samples: Float32Array }): Blob {
|
||||
const numChannels = NUM_CHANNELS;
|
||||
const bitsPerSample = 16;
|
||||
const bytesPerSample = bitsPerSample / 8;
|
||||
const numSamples = samples.length / numChannels;
|
||||
const dataSize = numSamples * numChannels * bytesPerSample;
|
||||
const buffer = new ArrayBuffer(44 + dataSize);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
// riff header
|
||||
writeString({ view, offset: 0, str: "RIFF" });
|
||||
view.setUint32(4, 36 + dataSize, true);
|
||||
writeString({ view, offset: 8, str: "WAVE" });
|
||||
|
||||
// fmt chunk
|
||||
writeString({ view, offset: 12, str: "fmt " });
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, numChannels, true);
|
||||
view.setUint32(24, SAMPLE_RATE, true);
|
||||
view.setUint32(28, SAMPLE_RATE * numChannels * bytesPerSample, true);
|
||||
view.setUint16(32, numChannels * bytesPerSample, true);
|
||||
view.setUint16(34, bitsPerSample, true);
|
||||
|
||||
// data chunk
|
||||
writeString({ view, offset: 36, str: "data" });
|
||||
view.setUint32(40, dataSize, true);
|
||||
|
||||
// convert float32 to int16 and write
|
||||
let offset = 44;
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const sample = Math.max(-1, Math.min(1, samples[i]));
|
||||
const int16 = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
||||
view.setInt16(offset, int16, true);
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
return new Blob([buffer], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
function writeString({
|
||||
view,
|
||||
offset,
|
||||
str,
|
||||
}: {
|
||||
view: DataView;
|
||||
offset: number;
|
||||
str: string;
|
||||
}): void {
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
view.setUint8(offset + i, str.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,9 @@ export function canToggleSourceAudio(
|
|||
element: TimelineElement,
|
||||
mediaAsset: MediaAudioState | null | undefined,
|
||||
): element is VideoElement {
|
||||
return canRecoverSourceAudio(element) || canExtractSourceAudio(element, mediaAsset);
|
||||
return (
|
||||
canRecoverSourceAudio(element) || canExtractSourceAudio(element, mediaAsset)
|
||||
);
|
||||
}
|
||||
|
||||
export function doesElementHaveEnabledAudio({
|
||||
|
|
@ -63,7 +65,11 @@ export function doesElementHaveEnabledAudio({
|
|||
return true;
|
||||
}
|
||||
|
||||
return !!mediaAsset && mediaAsset.hasAudio !== false && isSourceAudioEnabled({ element });
|
||||
return (
|
||||
!!mediaAsset &&
|
||||
mediaAsset.hasAudio !== false &&
|
||||
isSourceAudioEnabled({ element })
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSeparatedAudioElement({
|
||||
|
|
@ -100,7 +106,9 @@ export function getSourceAudioActionLabel({
|
|||
}: {
|
||||
element: VideoElement;
|
||||
}): "Extract audio" | "Recover audio" {
|
||||
return isSourceAudioSeparated({ element }) ? "Recover audio" : "Extract audio";
|
||||
return isSourceAudioSeparated({ element })
|
||||
? "Recover audio"
|
||||
: "Extract audio";
|
||||
}
|
||||
|
||||
function cloneVolumeAnimations({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import type { DiagnosticsManager } from "@/core/managers/diagnostics-manager";
|
||||
import { timelineHasAudio } from "@/lib/media/audio";
|
||||
|
||||
export const TRANSCRIPTION_DIAGNOSTICS_SCOPE = "transcription";
|
||||
|
||||
export function registerTranscriptionDiagnostics({
|
||||
diagnostics,
|
||||
}: {
|
||||
diagnostics: DiagnosticsManager;
|
||||
}): void {
|
||||
diagnostics.register({
|
||||
id: "transcription.no_audio",
|
||||
scope: TRANSCRIPTION_DIAGNOSTICS_SCOPE,
|
||||
severity: "caution",
|
||||
message: "No audio detected. Add a clip with audio to the timeline first.",
|
||||
check: (editor) => {
|
||||
const scene = editor.scenes.getActiveSceneOrNull();
|
||||
if (!scene) return false;
|
||||
return !timelineHasAudio({
|
||||
tracks: scene.tracks,
|
||||
mediaAssets: editor.media.getAssets(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue