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