Content
diff --git a/app/components/media/Transitions.tsx b/app/components/media/Transitions.tsx
index fc5e6b6..dfb60c6 100644
--- a/app/components/media/Transitions.tsx
+++ b/app/components/media/Transitions.tsx
@@ -1,180 +1,181 @@
import React, { useState } from "react";
import { Card, CardContent } from "~/components/ui/card";
import { FPS } from "../timeline/types";
+import { TransitionDragPayloadSchema } from "~/schemas";
import { generateUUID } from "~/utils/uuid";
// Data router loader (no data needed, ensures route is compatible with data router)
export function loader() {
- return null;
+ return null;
}
type TransitionType = {
- type: "fade" | "wipe" | "clockWipe" | "slide" | "flip" | "iris";
- name: string;
- description: string;
+ type: "fade" | "wipe" | "clockWipe" | "slide" | "flip" | "iris";
+ name: string;
+ description: string;
};
const transitionTypes: TransitionType[] = [
- {
- type: "fade",
- name: "fade()",
- description: "Animate the opacity of the scenes",
- },
- {
- type: "slide",
- name: "slide()",
- description: "Slide in and push out the previous scene",
- },
- {
- type: "wipe",
- name: "wipe()",
- description: "Slide over the previous scene",
- },
- {
- type: "flip",
- name: "flip()",
- description: "Rotate the previous scene",
- },
- {
- type: "clockWipe",
- name: "clockWipe()",
- description: "Reveal the new scene in a circular movement",
- },
- {
- type: "iris",
- name: "iris()",
- description: "Reveal the scene through a circular mask from center",
- },
+ {
+ type: "fade",
+ name: "fade()",
+ description: "Animate the opacity of the scenes",
+ },
+ {
+ type: "slide",
+ name: "slide()",
+ description: "Slide in and push out the previous scene",
+ },
+ {
+ type: "wipe",
+ name: "wipe()",
+ description: "Slide over the previous scene",
+ },
+ {
+ type: "flip",
+ name: "flip()",
+ description: "Rotate the previous scene",
+ },
+ {
+ type: "clockWipe",
+ name: "clockWipe()",
+ description: "Reveal the new scene in a circular movement",
+ },
+ {
+ type: "iris",
+ name: "iris()",
+ description: "Reveal the scene through a circular mask from center",
+ },
];
-const TransitionThumbnail = ({ transition, isSelected, onClick }: {
- transition: TransitionType;
- isSelected: boolean;
- onClick: () => void;
+const TransitionThumbnail = ({
+ transition,
+ isSelected,
+ onClick,
+}: {
+ transition: TransitionType;
+ isSelected: boolean;
+ onClick: () => void;
}) => {
- const handleDragStart = (e: React.DragEvent) => {
- const transitionData = {
- id: generateUUID(),
- type: "transition",
- presentation: transition.type,
- timing: "linear",
- durationInFrames: 1 * FPS,
- leftScrubberId: null,
- rightScrubberId: null,
- };
- e.dataTransfer.setData("application/json", JSON.stringify(transitionData));
- e.dataTransfer.effectAllowed = "copy";
+ const handleDragStart = (e: React.DragEvent) => {
+ const transitionData = {
+ id: generateUUID(),
+ type: "transition",
+ presentation: transition.type,
+ timing: "linear",
+ durationInFrames: 1 * FPS,
+ leftScrubberId: null,
+ rightScrubberId: null,
};
+ // Validate payload via centralized schema
+ const payload = TransitionDragPayloadSchema.parse(transitionData);
+ e.dataTransfer.setData("application/json", JSON.stringify(payload));
+ e.dataTransfer.effectAllowed = "copy";
+ };
- const renderTransitionEffect = () => {
- const baseClasses = "absolute rounded-sm";
-
- switch (transition.type) {
- case "fade":
- return (
- <>
-
-
- >
- );
- case "slide":
- return (
- <>
-
-
- >
- );
- case "wipe":
- return (
- <>
-
-
- >
- );
- case "flip":
- return (
- <>
-
-
- >
- );
- case "clockWipe":
- return (
- <>
-
-
- >
- );
- case "iris":
- return (
- <>
-
-
- >
- );
- }
- };
+ const renderTransitionEffect = () => {
+ const baseClasses = "absolute rounded-sm";
+
+ switch (transition.type) {
+ case "fade":
+ return (
+ <>
+
+
+ >
+ );
+ case "slide":
+ return (
+ <>
+
+
+ >
+ );
+ case "wipe":
+ return (
+ <>
+
+
+ >
+ );
+ case "flip":
+ return (
+ <>
+
+
+ >
+ );
+ case "clockWipe":
+ return (
+ <>
+
+
+ >
+ );
+ case "iris":
+ return (
+ <>
+
+
+ >
+ );
+ }
+ };
- return (
-
-
-
- {/* Thumbnail */}
-
- {renderTransitionEffect()}
-
+ return (
+
+
+
+ {/* Thumbnail */}
+
{renderTransitionEffect()}
- {/* Title and description */}
-
-
-
- {transition.name}
-
-
-
- {transition.description}
-
-
-
-
-
- );
+ {/* Title and description */}
+
+
+ {transition.name}
+
+
{transition.description}
+
+
+
+
+ );
};
export default function Transitions() {
- const [selectedTransition, setSelectedTransition] = useState
(null);
+ const [selectedTransition, setSelectedTransition] = useState(null);
- return (
-
-
- {/* Transitions Grid */}
-
- {transitionTypes.map((transition) => (
- setSelectedTransition(transition.type)}
- />
- ))}
-
-
+ return (
+
+
+ {/* Transitions Grid */}
+
+ {transitionTypes.map((transition) => (
+ setSelectedTransition(transition.type)}
+ />
+ ))}
- );
+
+
+ );
}
diff --git a/app/components/timeline/MediaBin.tsx b/app/components/timeline/MediaBin.tsx
index 454fe96..bbaf085 100644
--- a/app/components/timeline/MediaBin.tsx
+++ b/app/components/timeline/MediaBin.tsx
@@ -44,7 +44,7 @@ interface MediaBinProps {
fontFamily: string,
color: string,
textAlign: "left" | "center" | "right",
- fontWeight: "normal" | "bold"
+ fontWeight: "normal" | "bold",
) => void;
contextMenu: {
x: number;
@@ -55,38 +55,33 @@ interface MediaBinProps {
handleDeleteFromContext: () => Promise
;
handleSplitAudioFromContext: () => Promise;
handleCloseContextMenu: () => void;
+ // Persisted UI state from parent so grouping/sorting survives tab switches
+ arrangeModeExternal?: "default" | "group";
+ sortByExternal?: "default" | "name_asc" | "name_desc";
+ onArrangeModeChange?: (mode: "default" | "group") => void;
+ onSortByChange?: (sort: "default" | "name_asc" | "name_desc") => void;
}
// Memoized component for video thumbnails to prevent flickering
-const VideoThumbnail = memo(
- ({
- mediaUrl,
- width,
- height,
- }: {
- mediaUrl: string;
- width: number;
- height: number;
- }) => {
- const VideoComponent = useMemo(() => {
- return () => ;
- }, [mediaUrl]);
+const VideoThumbnail = memo(({ mediaUrl, width, height }: { mediaUrl: string; width: number; height: number }) => {
+ const VideoComponent = useMemo(() => {
+ return () => ;
+ }, [mediaUrl]);
- return (
-
-
-
- );
- }
-);
+ return (
+
+
+
+ );
+});
// Compact custom audio preview (no extra containers, minimal, design-token aware)
const AudioPreview = ({ src }: { src: string }) => {
@@ -108,7 +103,7 @@ const AudioPreview = ({ src }: { src: string }) => {
const el = audioRef.current;
if (!el) return;
if (el.paused) {
- el.play().catch(() => { });
+ el.play().catch(() => {});
} else {
el.pause();
}
@@ -139,7 +134,7 @@ const AudioPreview = ({ src }: { src: string }) => {
const pct = rect.width ? x / rect.width : 0;
el.currentTime = pct * duration;
},
- [duration]
+ [duration],
);
const onPointerDownTrack = useCallback(
@@ -149,7 +144,7 @@ const AudioPreview = ({ src }: { src: string }) => {
setIsScrubbing(true);
seekFromClientX(e.clientX);
},
- [seekFromClientX]
+ [seekFromClientX],
);
useEffect(() => {
@@ -178,26 +173,17 @@ const AudioPreview = ({ src }: { src: string }) => {
size="sm"
className="h-7 w-7 p-0 bg-transparent hover:bg-transparent"
onClick={togglePlay}
- title={isPlaying ? "Pause" : "Play"}
- >
- {isPlaying ? (
-
- ) : (
-
- )}
+ title={isPlaying ? "Pause" : "Play"}>
+ {isPlaying ? : }
+ className="relative w-full h-0.5 rounded cursor-pointer bg-black/25 dark:bg-white/25">
0
- ? (Math.min(currentTime, duration) / duration) * 100
- : 0
- }%`,
+ width: `${duration > 0 ? (Math.min(currentTime, duration) / duration) * 100 : 0}%`,
}}
/>
@@ -209,13 +195,8 @@ const AudioPreview = ({ src }: { src: string }) => {
size="sm"
className="h-7 w-7 p-0 bg-transparent hover:bg-transparent"
onClick={toggleMute}
- title={isMuted ? "Unmute" : "Mute"}
- >
- {isMuted ? (
-
- ) : (
-
- )}
+ title={isMuted ? "Unmute" : "Mute"}>
+ {isMuted ?
:
}
();
// Drag & Drop state for external file imports
const [isDragOver, setIsDragOver] = useState(false);
- // Arrange & sorting state
- const [arrangeMode, setArrangeMode] = useState<"default" | "group">(
- "default"
+ // Arrange & sorting state (controlled by parent when provided)
+ const [arrangeMode, setArrangeMode] = useState<"default" | "group">(arrangeModeExternal ?? "default");
+ const [sortBy, setSortBy] = useState<"default" | "name_asc" | "name_desc">(sortByExternal ?? "default");
+
+ // Sync from parent if it changes
+ useEffect(() => {
+ if (arrangeModeExternal && arrangeModeExternal !== arrangeMode) setArrangeMode(arrangeModeExternal);
+ }, [arrangeModeExternal]);
+ useEffect(() => {
+ if (sortByExternal && sortByExternal !== sortBy) setSortBy(sortByExternal);
+ }, [sortByExternal]);
+
+ const updateArrangeMode = useCallback(
+ (mode: "default" | "group") => {
+ setArrangeMode(mode);
+ onArrangeModeChange?.(mode);
+ },
+ [onArrangeModeChange],
);
- const [sortBy, setSortBy] = useState<"default" | "name_asc" | "name_desc">(
- "default"
+
+ const updateSortBy = useCallback(
+ (sort: "default" | "name_asc" | "name_desc") => {
+ setSortBy(sort);
+ onSortByChange?.(sort);
+ },
+ [onSortByChange],
);
const [collapsed, setCollapsed] = useState<{
[key in "videos" | "gifs" | "images" | "audio" | "text"]: boolean;
@@ -268,26 +273,20 @@ export default function MediaBin() {
text: false,
});
- const handleDragOverRoot = useCallback(
- (e: React.DragEvent) => {
- // Only react to file drags from OS, not internal element drags
- if (!Array.from(e.dataTransfer.types).includes("Files")) return;
- e.preventDefault();
- e.dataTransfer.dropEffect = "copy";
- setIsDragOver(true);
- },
- []
- );
+ const handleDragOverRoot = useCallback((e: React.DragEvent) => {
+ // Only react to file drags from OS, not internal element drags
+ if (!Array.from(e.dataTransfer.types).includes("Files")) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "copy";
+ setIsDragOver(true);
+ }, []);
- const handleDragLeaveRoot = useCallback(
- (e: React.DragEvent) => {
- if (!Array.from(e.dataTransfer.types).includes("Files")) return;
- // Only reset when leaving the current target
- if (e.currentTarget.contains(e.relatedTarget as Node)) return;
- setIsDragOver(false);
- },
- []
- );
+ const handleDragLeaveRoot = useCallback((e: React.DragEvent) => {
+ if (!Array.from(e.dataTransfer.types).includes("Files")) return;
+ // Only reset when leaving the current target
+ if (e.currentTarget.contains(e.relatedTarget as Node)) return;
+ setIsDragOver(false);
+ }, []);
const handleDropRoot = useCallback(
async (e: React.DragEvent) => {
@@ -298,52 +297,14 @@ export default function MediaBin() {
const isAllowed = (file: File) => {
const type = (file.type || "").toLowerCase();
- if (
- type.startsWith("video/") ||
- type.startsWith("audio/") ||
- type.startsWith("image/")
- ) {
+ if (type.startsWith("video/") || type.startsWith("audio/") || type.startsWith("image/")) {
return true; // includes GIF via image/gif
}
// Fallback by extension when MIME is missing
const name = file.name.toLowerCase();
- const imageExts = [
- ".png",
- ".jpg",
- ".jpeg",
- ".webp",
- ".bmp",
- ".gif",
- ".tiff",
- ".svg",
- ".heic",
- ".heif",
- ];
- const videoExts = [
- ".mp4",
- ".mov",
- ".mkv",
- ".webm",
- ".avi",
- ".m4v",
- ".wmv",
- ".mts",
- ".m2ts",
- ".3gp",
- ".flv",
- ];
- const audioExts = [
- ".mp3",
- ".wav",
- ".aac",
- ".flac",
- ".m4a",
- ".ogg",
- ".opus",
- ".aiff",
- ".aif",
- ".wma",
- ];
+ const imageExts = [".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tiff", ".svg", ".heic", ".heif"];
+ const videoExts = [".mp4", ".mov", ".mkv", ".webm", ".avi", ".m4v", ".wmv", ".mts", ".m2ts", ".3gp", ".flv"];
+ const audioExts = [".mp3", ".wav", ".aac", ".flac", ".m4a", ".ogg", ".opus", ".aiff", ".aif", ".wma"];
const all = [...imageExts, ...videoExts, ...audioExts];
return all.some((ext) => name.endsWith(ext));
};
@@ -357,7 +318,7 @@ export default function MediaBin() {
}
}
},
- [onAddMedia]
+ [onAddMedia],
);
const getMediaIcon = (mediaType: string) => {
@@ -385,9 +346,7 @@ export default function MediaBin() {
const counts = useMemo(() => {
const videos = mediaBinItems.filter((i) => i.mediaType === "video").length;
const gifs = mediaBinItems.filter(isGif).length;
- const images = mediaBinItems.filter(
- (i) => i.mediaType === "image" && !isGif(i)
- ).length;
+ const images = mediaBinItems.filter((i) => i.mediaType === "image" && !isGif(i)).length;
const audio = mediaBinItems.filter((i) => i.mediaType === "audio").length;
const text = mediaBinItems.filter((i) => i.mediaType === "text").length;
const all = mediaBinItems.length;
@@ -395,27 +354,21 @@ export default function MediaBin() {
}, [mediaBinItems]);
const defaultArrangedItems = useMemo(() => {
- if (sortBy === "name_asc")
- return [...mediaBinItems].sort((a, b) => a.name.localeCompare(b.name));
- if (sortBy === "name_desc")
- return [...mediaBinItems].sort((a, b) => b.name.localeCompare(a.name));
+ if (sortBy === "name_asc") return [...mediaBinItems].sort((a, b) => a.name.localeCompare(b.name));
+ if (sortBy === "name_desc") return [...mediaBinItems].sort((a, b) => b.name.localeCompare(a.name));
return mediaBinItems;
}, [mediaBinItems, sortBy]);
const groupedItems = useMemo(() => {
const videos = mediaBinItems.filter((i) => i.mediaType === "video");
const gifs = mediaBinItems.filter(isGif);
- const images = mediaBinItems.filter(
- (i) => i.mediaType === "image" && !isGif(i)
- );
+ const images = mediaBinItems.filter((i) => i.mediaType === "image" && !isGif(i));
const audio = mediaBinItems.filter((i) => i.mediaType === "audio");
const text = mediaBinItems.filter((i) => i.mediaType === "text");
const maybeSort = (arr: MediaBinItem[]) => {
- if (sortBy === "name_asc")
- return [...arr].sort((a, b) => a.name.localeCompare(b.name));
- if (sortBy === "name_desc")
- return [...arr].sort((a, b) => b.name.localeCompare(a.name));
+ if (sortBy === "name_asc") return [...arr].sort((a, b) => a.name.localeCompare(b.name));
+ if (sortBy === "name_desc") return [...arr].sort((a, b) => b.name.localeCompare(a.name));
return arr;
};
@@ -440,11 +393,7 @@ export default function MediaBin() {
case "video":
if (mediaUrl) {
return (
-
+
);
}
return ;
@@ -459,9 +408,7 @@ export default function MediaBin() {
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.style.display = "none";
- e.currentTarget.nextElementSibling?.classList.remove(
- "hidden"
- );
+ e.currentTarget.nextElementSibling?.classList.remove("hidden");
}}
/>
@@ -525,15 +472,12 @@ export default function MediaBin() {
onDragOver={handleDragOverRoot}
onDragEnter={handleDragOverRoot}
onDragLeave={handleDragLeaveRoot}
- onDrop={handleDropRoot}
- >
+ onDrop={handleDropRoot}>
{/* Compact Header */}
-
- Media Library
-
+ Media Library
{mediaBinItems.length}
@@ -544,27 +488,23 @@ export default function MediaBin() {
setArrangeMode("default")}
+ className={`h-5 w-5 p-0 bg-transparent hover:bg-transparent ${
+ arrangeMode === "default" ? "text-primary" : "text-muted-foreground/70 hover:text-foreground"
+ }`}
+ onClick={() => updateArrangeMode("default")}
title="Default order"
- aria-pressed={arrangeMode === "default"}
- >
+ aria-pressed={arrangeMode === "default"}>
setArrangeMode("group")}
+ className={`h-5 w-5 p-0 bg-transparent hover:bg-transparent ${
+ arrangeMode === "group" ? "text-primary" : "text-muted-foreground/70 hover:text-foreground"
+ }`}
+ onClick={() => updateArrangeMode("group")}
title="Smart Group"
- aria-pressed={arrangeMode === "group"}
- >
+ aria-pressed={arrangeMode === "group"}>
@@ -578,51 +518,30 @@ export default function MediaBin() {
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-muted-foreground/70 hover:text-foreground bg-transparent hover:bg-transparent"
- title="Sort"
- >
+ title="Sort">
-
- Sort
-
+ Sort
setSortBy("default")}
- className={`text-[12px] gap-2 ${sortBy === "default" ? "text-primary" : ""
- }`}
- data-variant="ghost"
- >
- {" "}
- Original order
+ onClick={() => updateSortBy("default")}
+ className={`text-[12px] gap-2 ${sortBy === "default" ? "text-primary" : ""}`}
+ data-variant="ghost">
+ Original order
setSortBy("name_asc")}
- className={`text-[12px] gap-2 ${sortBy === "name_asc" ? "text-primary" : ""
- }`}
- data-variant="ghost"
- >
- {" "}
- Name A–Z
+ onClick={() => updateSortBy("name_asc")}
+ className={`text-[12px] gap-2 ${sortBy === "name_asc" ? "text-primary" : ""}`}
+ data-variant="ghost">
+ Name A–Z
setSortBy("name_desc")}
- className={`text-[12px] gap-2 ${sortBy === "name_desc" ? "text-primary" : ""
- }`}
- data-variant="ghost"
- >
- {" "}
- Name Z–A
+ onClick={() => updateSortBy("name_desc")}
+ className={`text-[12px] gap-2 ${sortBy === "name_desc" ? "text-primary" : ""}`}
+ data-variant="ghost">
+ Name Z–A
@@ -642,68 +561,57 @@ export default function MediaBin() {
{defaultArrangedItems.map((item) => (
{
if (!item.isUploading) {
- e.dataTransfer.setData(
- "application/json",
- JSON.stringify(item)
- );
+ e.dataTransfer.setData("application/json", JSON.stringify(item));
console.log("Dragging item:", item.name);
}
}}
onContextMenu={(e) => handleContextMenu(e, item)}
- onDoubleClick={() => openPreview(item)}
- >
+ onDoubleClick={() => openPreview(item)}>
{renderThumbnail(item)}
+ className={`text-xs font-medium truncate transition-colors ${
+ item.isUploading
+ ? "text-muted-foreground"
+ : "text-foreground group-hover:text-accent-foreground"
+ }`}>
{item.name}
- {item.isUploading &&
- typeof item.uploadProgress === "number" && (
-
- {item.uploadProgress}%
-
- )}
+ {item.isUploading && typeof item.uploadProgress === "number" && (
+
{item.uploadProgress}%
+ )}
- {item.isUploading &&
- typeof item.uploadProgress === "number" && (
-
- )}
+ {item.isUploading && typeof item.uploadProgress === "number" && (
+
+ )}
-
+
{item.isUploading ? "uploading" : item.mediaType}
- {(item.mediaType === "video" || item.mediaType === "audio" || item.mediaType === "groupped_scrubber") && item.durationInSeconds > 0 && !item.isUploading && (
-
-
- {item.durationInSeconds.toFixed(1)}s
-
- )}
+ {(item.mediaType === "video" ||
+ item.mediaType === "audio" ||
+ item.mediaType === "groupped_scrubber") &&
+ item.durationInSeconds > 0 &&
+ !item.isUploading && (
+
+
+ {item.durationInSeconds.toFixed(1)}s
+
+ )}
@@ -758,10 +666,7 @@ export default function MediaBin() {
]
.filter((section) => section.count > 0)
.map((section) => (
-
+
@@ -769,36 +674,20 @@ export default function MediaBin() {
...prev,
[section.key]: !prev[section.key],
}))
- }
- >
+ }>
- {section.key === "videos" && (
-
- )}
- {section.key === "gifs" && (
-
- )}
- {section.key === "images" && (
-
- )}
+ {section.key === "videos" && }
+ {section.key === "gifs" && }
+ {section.key === "images" && }
{section.key === "audio" && }
{section.key === "text" && }
-
- {section.title}
-
+ {section.title}
-
+
{section.count}
- {collapsed[section.key] ? (
-
- ) : (
-
- )}
+ {collapsed[section.key] ? : }
{!collapsed[section.key] && (
@@ -806,66 +695,46 @@ export default function MediaBin() {
{section.items.map((item) => (
{
if (!item.isUploading) {
- e.dataTransfer.setData(
- "application/json",
- JSON.stringify(item)
- );
+ e.dataTransfer.setData("application/json", JSON.stringify(item));
}
}}
onContextMenu={(e) => handleContextMenu(e, item)}
- onDoubleClick={() => openPreview(item)}
- >
+ onDoubleClick={() => openPreview(item)}>
-
- {renderThumbnail(item)}
-
+
{renderThumbnail(item)}
+ className={`text-xs font-medium truncate ${
+ item.isUploading ? "text-muted-foreground" : "text-foreground"
+ }`}>
{item.name}
- {item.isUploading &&
- typeof item.uploadProgress === "number" && (
-
- {item.uploadProgress}%
-
- )}
+ {item.isUploading && typeof item.uploadProgress === "number" && (
+
+ {item.uploadProgress}%
+
+ )}
- {item.isUploading &&
- typeof item.uploadProgress === "number" && (
-
- )}
+ {item.isUploading && typeof item.uploadProgress === "number" && (
+
+ )}
-
- {item.isUploading
- ? "uploading"
- : item.mediaType}
+
+ {item.isUploading ? "uploading" : item.mediaType}
- {(item.mediaType === "video" ||
- item.mediaType === "audio") &&
+ {(item.mediaType === "video" || item.mediaType === "audio") &&
item.durationInSeconds > 0 &&
!item.isUploading && (
@@ -902,9 +771,7 @@ export default function MediaBin() {
-
- Drop files to import
-
+
Drop files to import
@@ -918,20 +785,17 @@ export default function MediaBin() {
left: contextMenu.x,
top: contextMenu.y,
}}
- onClick={(e) => e.stopPropagation()}
- >
+ onClick={(e) => e.stopPropagation()}>
+ onClick={handleDeleteFromContext}>
Delete Media
{contextMenu.item.mediaType === "video" && (
+ onClick={handleSplitAudioFromContext}>
Split Audio
@@ -950,68 +814,43 @@ export default function MediaBin() {
}}
tabIndex={-1}
role="dialog"
- aria-modal="true"
- >
+ aria-modal="true">
e.stopPropagation()}
- >
+ onClick={(e) => e.stopPropagation()}>
{previewItem.mediaType}
-
- {previewItem.name}
-
+
{previewItem.name}
-
+
Close
{previewItem.mediaType === "video" && (
)}
{previewItem.mediaType === "image" && (
)}
{previewItem.mediaType === "audio" && (
-
+
)}
{previewItem.mediaType === "text" && (
-
- {previewItem.text?.textContent || previewItem.name}
-
+
{previewItem.text?.textContent || previewItem.name}
)}
diff --git a/app/components/timeline/TimelineTracks.tsx b/app/components/timeline/TimelineTracks.tsx
index 3a30fd4..9b9b09a 100644
--- a/app/components/timeline/TimelineTracks.tsx
+++ b/app/components/timeline/TimelineTracks.tsx
@@ -12,6 +12,7 @@ import {
type TimelineState,
type Transition,
} from "./types";
+import { MediaBinItemSchema } from "~/schemas/components/timeline";
interface TimelineTracksProps {
timeline: TimelineState;
@@ -23,16 +24,8 @@ interface TimelineTracksProps {
onUpdateScrubber: (updatedScrubber: ScrubberState) => void;
onDeleteScrubber?: (scrubberId: string) => void;
onBeginScrubberTransform?: () => void;
- onDropOnTrack: (
- item: MediaBinItem,
- trackId: string,
- dropLeftPx: number
- ) => void;
- onDropTransitionOnTrack: (
- transition: Transition,
- trackId: string,
- dropLeftPx: number
- ) => void;
+ onDropOnTrack: (item: MediaBinItem, trackId: string, dropLeftPx: number) => void;
+ onDropTransitionOnTrack: (transition: Transition, trackId: string, dropLeftPx: number) => void;
onDeleteTransition: (transitionId: string) => void;
getAllScrubbers: () => ScrubberState[];
expandTimeline: () => boolean;
@@ -110,22 +103,19 @@ export const TimelineTracks: React.FC
= ({
style={{
transform: `translateY(-${containerRef.current?.scrollTop || 0}px)`,
height: `${timeline.tracks.length * DEFAULT_TRACK_HEIGHT}px`,
- }}
- >
+ }}>
{timeline.tracks.map((track, index) => (
+ style={{ height: `${DEFAULT_TRACK_HEIGHT}px` }}>
onDeleteTrack(track.id)}
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-sm"
title={`Delete Track ${index + 1}`}
- aria-label={`Delete Track ${index + 1}`}
- >
+ aria-label={`Delete Track ${index + 1}`}>
Track {index + 1}
@@ -139,10 +129,10 @@ export const TimelineTracks: React.FC
= ({
{/* Scrollable Tracks Area */}
0 ? onScroll : undefined}
- >
+ className={`relative flex-1 bg-timeline-background timeline-scrollbar ${
+ timeline.tracks.length === 0 ? "overflow-hidden" : "overflow-auto"
+ }`}
+ onScroll={timeline.tracks.length > 0 ? onScroll : undefined}>
{timeline.tracks.length === 0 ? (
/* Empty state - non-scrollable and centered */
@@ -155,10 +145,7 @@ export const TimelineTracks: React.FC
= ({
className="absolute top-0 w-0.5 bg-primary pointer-events-none z-40"
style={{
left: `${rulerPositionPx}px`,
- height: `${Math.max(
- timeline.tracks.length * DEFAULT_TRACK_HEIGHT,
- 200
- )}px`,
+ height: `${Math.max(timeline.tracks.length * DEFAULT_TRACK_HEIGHT, 200)}px`,
}}
/>
@@ -197,13 +184,8 @@ export const TimelineTracks: React.FC = ({
const dropXInTimeline = e.clientX - containerBounds.left + scrollLeft;
const dropYInTimeline = e.clientY - containerBounds.top + scrollTop;
- let trackIndex = Math.floor(
- dropYInTimeline / DEFAULT_TRACK_HEIGHT
- );
- trackIndex = Math.max(
- 0,
- Math.min(timeline.tracks.length - 1, trackIndex)
- );
+ let trackIndex = Math.floor(dropYInTimeline / DEFAULT_TRACK_HEIGHT);
+ trackIndex = Math.max(0, Math.min(timeline.tracks.length - 1, trackIndex));
const trackId = timeline.tracks[trackIndex]?.id;
@@ -217,23 +199,24 @@ export const TimelineTracks: React.FC = ({
onDropTransitionOnTrack(data, trackId, dropXInTimeline);
} else {
// Handle media item drop
- onDropOnTrack(data as MediaBinItem, trackId, dropXInTimeline);
+ try {
+ const validated = MediaBinItemSchema.parse(data);
+ onDropOnTrack(validated as unknown as MediaBinItem, trackId, dropXInTimeline);
+ } catch {
+ // Ignore invalid payloads
+ }
}
- }}
- >
+ }}>
{/* Track backgrounds and grid lines */}
{timeline.tracks.map((track, trackIndex) => (
-
+
{/* Track background */}
= ({
*/}
{/* Grid lines */}
- {Array.from(
- { length: Math.floor(timelineWidth / pixelsPerSecond) + 1 },
- (_, index) => index
- ).map((gridIndex) => (
-
- ))}
+ {Array.from({ length: Math.floor(timelineWidth / pixelsPerSecond) + 1 }, (_, index) => index).map(
+ (gridIndex) => (
+
+ ),
+ )}
))}
{/* Scrubbers */}
{getAllScrubbers().map((scrubber) => {
// Get all transitions for the track containing this scrubber
- const scrubberTrack = timeline.tracks.find(track =>
- track.scrubbers.some(s => s.id === scrubber.id)
+ const scrubberTrack = timeline.tracks.find((track) =>
+ track.scrubbers.some((s) => s.id === scrubber.id),
);
return (
@@ -287,9 +268,7 @@ export const TimelineTracks: React.FC
= ({
key={scrubber.id}
scrubber={scrubber}
timelineWidth={timelineWidth}
- otherScrubbers={getAllScrubbers().filter(
- (s) => s.id !== scrubber.id
- )}
+ otherScrubbers={getAllScrubbers().filter((s) => s.id !== scrubber.id)}
onUpdate={onUpdateScrubber}
onDelete={onDeleteScrubber}
isSelected={selectedScrubberIds.includes(scrubber.id)}
@@ -313,13 +292,15 @@ export const TimelineTracks: React.FC = ({
const transitionComponents = [];
// Get all scrubbers across all tracks for transition lookup
const allScrubbers = getAllScrubbers();
-
+
for (const track of timeline.tracks) {
for (const transition of track.transitions) {
- const leftScrubber = transition.leftScrubberId ?
- allScrubbers.find(s => s.id === transition.leftScrubberId) || null : null;
- const rightScrubber = transition.rightScrubberId ?
- allScrubbers.find(s => s.id === transition.rightScrubberId) || null : null;
+ const leftScrubber = transition.leftScrubberId
+ ? allScrubbers.find((s) => s.id === transition.leftScrubberId) || null
+ : null;
+ const rightScrubber = transition.rightScrubberId
+ ? allScrubbers.find((s) => s.id === transition.rightScrubberId) || null
+ : null;
if (leftScrubber == null && rightScrubber == null) {
continue;
@@ -333,7 +314,7 @@ export const TimelineTracks: React.FC = ({
rightScrubber={rightScrubber}
pixelsPerSecond={pixelsPerSecond}
onDelete={onDeleteTransition}
- />
+ />,
);
}
}
diff --git a/app/components/ui/resizable.tsx b/app/components/ui/resizable.tsx
index 28c4ee9..aec7b95 100644
--- a/app/components/ui/resizable.tsx
+++ b/app/components/ui/resizable.tsx
@@ -1,54 +1,48 @@
-import * as React from "react"
-import { GripVerticalIcon } from "lucide-react"
-import * as ResizablePrimitive from "react-resizable-panels"
+import * as React from "react";
+import { GripVerticalIcon } from "lucide-react";
+import * as ResizablePrimitive from "react-resizable-panels";
-import { cn } from "~/lib/utils"
+import { cn } from "~/lib/utils";
-function ResizablePanelGroup({
- className,
- ...props
-}: React.ComponentProps) {
+function ResizablePanelGroup({ className, ...props }: React.ComponentProps) {
return (
- )
+ );
}
-function ResizablePanel({
- ...props
-}: React.ComponentProps) {
- return
-}
+const ResizablePanel = React.forwardRef<
+ ResizablePrimitive.ImperativePanelHandle,
+ React.ComponentProps
+>(function ResizablePanel(props, ref) {
+ return ;
+});
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps & {
- withHandle?: boolean
+ withHandle?: boolean;
}) {
return (
div]:rotate-90",
- className
+ className,
)}
- {...props}
- >
+ {...props}>
{withHandle && (
)}
- )
+ );
}
-export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
diff --git a/app/hooks/useAuth.ts b/app/hooks/useAuth.ts
index 0aa227d..6ceb399 100644
--- a/app/hooks/useAuth.ts
+++ b/app/hooks/useAuth.ts
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { apiUrl } from "~/utils/api";
import { authClient } from "~/lib/auth.client";
import { useNavigate } from "react-router";
+import { normalizeAuthUser } from "~/schemas/auth";
interface AuthUser {
id: string;
@@ -59,25 +60,8 @@ export function useAuth(): UseAuthResult {
useEffect(() => {
let isMounted = true;
const extractUser = (data: unknown): AuthUser | null => {
- if (!data || typeof data !== "object") return null;
-
- const dataObj = data as AuthResponse;
- const raw = dataObj.user || dataObj?.data?.user || dataObj?.session?.user || null;
-
- if (raw) {
- return {
- id: String(raw.id ?? raw.userId ?? ""),
- email: raw.email ?? null,
- name: raw.name ?? null,
- image: raw.image ?? raw.avatarUrl ?? null,
- };
- }
-
- if (dataObj.session?.userId) {
- return { id: String(dataObj.session.userId) } as AuthUser;
- }
-
- return null;
+ const u = normalizeAuthUser(data);
+ return u ? { id: u.id, email: u.email ?? null, name: u.name ?? null, image: u.image ?? null } : null;
};
// Fetch helpers return undefined on error (so we don't clear user)
diff --git a/app/hooks/useMediaBin.ts b/app/hooks/useMediaBin.ts
index e0d8798..6b4ef33 100644
--- a/app/hooks/useMediaBin.ts
+++ b/app/hooks/useMediaBin.ts
@@ -1,20 +1,18 @@
-import { useState, useCallback, useEffect } from "react"
-import axios from "axios"
-import { type MediaBinItem, type ScrubberState } from "~/components/timeline/types"
-import { generateUUID } from "~/utils/uuid"
-import { apiUrl } from "~/utils/api"
+import { useState, useCallback, useEffect } from "react";
+import axios from "axios";
+import { type MediaBinItem, type ScrubberState } from "~/components/timeline/types";
+import { generateUUID } from "~/utils/uuid";
+import { apiUrl } from "~/utils/api";
+import { AssetsResponseSchema } from "~/schemas";
// Delete media file from server
export const deleteMediaFile = async (
- filename: string
+ filename: string,
): Promise<{ success: boolean; message?: string; error?: string }> => {
try {
- const response = await fetch(
- apiUrl(`/media/${encodeURIComponent(filename)}`),
- {
- method: "DELETE",
- }
- );
+ const response = await fetch(apiUrl(`/media/${encodeURIComponent(filename)}`), {
+ method: "DELETE",
+ });
if (!response.ok) {
const errorData = await response.json();
@@ -35,7 +33,7 @@ export const deleteMediaFile = async (
export const cloneMediaFile = async (
filename: string,
originalName: string,
- suffix: string
+ suffix: string,
): Promise<{
success: boolean;
filename?: string;
@@ -76,7 +74,7 @@ export const cloneMediaFile = async (
// Helper function to get media metadata
const getMediaMetadata = (
file: File,
- mediaType: "video" | "image" | "audio"
+ mediaType: "video" | "image" | "audio",
): Promise<{
durationInSeconds?: number;
width: number;
@@ -96,9 +94,7 @@ const getMediaMetadata = (
URL.revokeObjectURL(url);
resolve({
- durationInSeconds: isFinite(durationInSeconds)
- ? durationInSeconds
- : undefined,
+ durationInSeconds: isFinite(durationInSeconds) ? durationInSeconds : undefined,
width,
height,
});
@@ -140,9 +136,7 @@ const getMediaMetadata = (
URL.revokeObjectURL(url);
resolve({
- durationInSeconds: isFinite(durationInSeconds)
- ? durationInSeconds
- : undefined,
+ durationInSeconds: isFinite(durationInSeconds) ? durationInSeconds : undefined,
width: 0, // Audio files don't have visual dimensions
height: 0,
});
@@ -158,9 +152,7 @@ const getMediaMetadata = (
});
};
-export const useMediaBin = (
- handleDeleteScrubbersByMediaBinId: (mediaBinId: string) => void
-) => {
+export const useMediaBin = (handleDeleteScrubbersByMediaBinId: (mediaBinId: string) => void) => {
const [mediaBinItems, setMediaBinItems] = useState([]);
const [isMediaLoading, setIsMediaLoading] = useState(true);
const projectId = (() => {
@@ -177,28 +169,21 @@ export const useMediaBin = (
item: MediaBinItem;
} | null>(null);
- // Hydrate existing assets for the logged-in user
- // DISABLED: Loading assets feature temporarily commented out
- /*
+ // Hydrate existing assets for the logged-in user and project
useEffect(() => {
const loadAssets = async () => {
try {
- const url = projectId
- ? `/api/assets?projectId=${encodeURIComponent(projectId)}`
- : "/api/assets";
+ const url = projectId ? `/api/assets?projectId=${encodeURIComponent(projectId)}` : "/api/assets";
const res = await fetch(apiUrl(url, false, true), {
credentials: "include",
});
- if (!res.ok) return;
+ if (!res.ok) {
+ console.warn("Failed to load assets:", res.status);
+ return;
+ }
const json = await res.json();
- const assets = (json.assets || []) as Array<{
- id: string;
- name: string;
- mediaUrlRemote: string;
- width: number | null;
- height: number | null;
- durationInSeconds: number | null;
- }>;
+ const parsed = AssetsResponseSchema.safeParse(json);
+ const assets = parsed.success ? parsed.data.assets : [];
const items: MediaBinItem[] = assets.map((a) => ({
id: a.id,
name: a.name,
@@ -219,12 +204,14 @@ export const useMediaBin = (
uploadProgress: null,
left_transition_id: null,
right_transition_id: null,
+ groupped_scrubbers: null,
}));
// Merge: keep existing text items, replace non-text items with fetched assets
setMediaBinItems((prev) => {
const textItems = prev.filter((i) => i.mediaType === "text");
return [...textItems, ...items];
});
+ console.log(`Loaded ${items.length} assets for project ${projectId || "default"}`);
} catch (e) {
console.error("Failed to load assets", e);
} finally {
@@ -233,12 +220,6 @@ export const useMediaBin = (
};
loadAssets();
}, [projectId]);
- */
-
- // Manually set loading to false since we're not loading assets
- useEffect(() => {
- setIsMediaLoading(false);
- }, []);
const handleAddMediaToBin = useCallback(async (file: File) => {
const id = generateUUID();
@@ -278,92 +259,100 @@ export const useMediaBin = (
right_transition_id: null,
groupped_scrubbers: null,
};
- setMediaBinItems(prev => [...prev, newItem]);
+ setMediaBinItems((prev) => [...prev, newItem]);
const formData = new FormData();
- formData.append('media', file);
+ formData.append("media", file);
console.log("Uploading file to server...");
- const uploadResponse = await axios.post(apiUrl('/upload'), formData, {
+ // Use the new authenticated upload endpoint with project support
+ const uploadResponse = await axios.post(apiUrl("/api/assets/upload", false, true), formData, {
+ headers: {
+ "X-Media-Width": metadata.width.toString(),
+ "X-Media-Height": metadata.height.toString(),
+ "X-Media-Duration": (metadata.durationInSeconds || 0).toString(),
+ "X-Original-Name": file.name,
+ "X-Project-Id": projectId || "",
+ },
+ withCredentials: true, // Include authentication cookies
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
console.log(`Upload progress: ${percentCompleted}%`);
// Update upload progress in the media bin
- setMediaBinItems(prev =>
- prev.map(item =>
- item.id === id
- ? { ...item, uploadProgress: percentCompleted }
- : item
- )
+ setMediaBinItems((prev) =>
+ prev.map((item) => (item.id === id ? { ...item, uploadProgress: percentCompleted } : item)),
);
}
- }
+ },
});
const uploadResult = uploadResponse.data;
console.log("Upload successful:", uploadResult);
// Update item with successful upload result and remove progress tracking
- setMediaBinItems(prev =>
- prev.map(item =>
+ setMediaBinItems((prev) =>
+ prev.map((item) =>
item.id === id
? {
- ...item,
- mediaUrlRemote: uploadResult.fullUrl,
- isUploading: false,
- uploadProgress: null
- }
- : item
- )
+ ...item,
+ id: uploadResult.asset.id, // Use the database-generated asset ID
+ mediaUrlRemote: uploadResult.asset.mediaUrlRemote,
+ isUploading: false,
+ uploadProgress: null,
+ }
+ : item,
+ ),
);
-
} catch (error) {
console.error("Error adding media to bin:", error);
const errorMessage = error instanceof Error ? error.message : "Unknown error";
// Remove the failed item from media bin
- setMediaBinItems(prev => prev.filter(item => item.id !== id));
+ setMediaBinItems((prev) => prev.filter((item) => item.id !== id));
throw new Error(`Failed to add media: ${errorMessage}`);
}
}, []);
- const handleAddTextToBin = useCallback((
- textContent: string,
- fontSize: number,
- fontFamily: string,
- color: string,
- textAlign: "left" | "center" | "right",
- fontWeight: "normal" | "bold"
- ) => {
- const newItem: MediaBinItem = {
- id: generateUUID(),
- name: textContent,
- mediaType: "text",
- media_width: 0,
- media_height: 0,
- text: {
- textContent,
- fontSize,
- fontFamily,
- color,
- textAlign,
- fontWeight,
- template: null, // for now, maybe we can also allow text to have a template (same ones from captions)
- },
- mediaUrlLocal: null,
- mediaUrlRemote: null,
- durationInSeconds: 0, // interesting code. i wish i remembered why i did this. maybe there's a better way.
- isUploading: false,
- uploadProgress: null,
- left_transition_id: null,
- right_transition_id: null,
- groupped_scrubbers: null,
- };
- setMediaBinItems(prev => [...prev, newItem]);
- }, []);
+ const handleAddTextToBin = useCallback(
+ (
+ textContent: string,
+ fontSize: number,
+ fontFamily: string,
+ color: string,
+ textAlign: "left" | "center" | "right",
+ fontWeight: "normal" | "bold",
+ ) => {
+ const newItem: MediaBinItem = {
+ id: generateUUID(),
+ name: textContent,
+ mediaType: "text",
+ media_width: 0,
+ media_height: 0,
+ text: {
+ textContent,
+ fontSize,
+ fontFamily,
+ color,
+ textAlign,
+ fontWeight,
+ template: null, // for now, maybe we can also allow text to have a template (same ones from captions)
+ },
+ mediaUrlLocal: null,
+ mediaUrlRemote: null,
+ durationInSeconds: 0, // interesting code. i wish i remembered why i did this. maybe there's a better way.
+ isUploading: false,
+ uploadProgress: null,
+ left_transition_id: null,
+ right_transition_id: null,
+ groupped_scrubbers: null,
+ };
+ setMediaBinItems((prev) => [...prev, newItem]);
+ },
+ [],
+ );
const getMediaBinItems = useCallback(() => mediaBinItems, [mediaBinItems]);
@@ -380,50 +369,47 @@ export const useMediaBin = (
mediaUrlRemote: null,
isUploading: false,
uploadProgress: null,
- })
+ }),
),
];
});
}, []);
- const handleDeleteMedia = useCallback(async (item: MediaBinItem) => {
- try {
- if (item.mediaType === "text" || item.mediaType === "groupped_scrubber") {
- setMediaBinItems(prev => prev.filter(binItem => binItem.id !== item.id));
-
- // Also remove any scrubbers from the timeline that use this media
- if (handleDeleteScrubbersByMediaBinId) {
- handleDeleteScrubbersByMediaBinId(item.id);
+ const handleDeleteMedia = useCallback(
+ async (item: MediaBinItem) => {
+ try {
+ // For text and grouped scrubbers, which are UI-only constructs, just remove them from the local state.
+ if (item.mediaType === "text" || item.mediaType === "groupped_scrubber") {
+ setMediaBinItems((prev) => prev.filter((binItem) => binItem.id !== item.id));
+ if (handleDeleteScrubbersByMediaBinId) {
+ handleDeleteScrubbersByMediaBinId(item.id);
+ }
+ return; // Exit early as there's no backend asset to delete.
}
- if (!item.mediaUrlRemote) {
- console.error("No remote URL found for media item");
- return;
- }
- }
- // Call authenticated delete by asset id
- const assetId = item.id;
- const res = await fetch(apiUrl(`/api/assets/${assetId}`, false, true), {
- method: "DELETE",
- credentials: "include",
- });
- if (res.ok) {
- console.log(`Media deleted: ${item.name}`);
- // Remove from media bin state
- setMediaBinItems((prev) =>
- prev.filter((binItem) => binItem.id !== item.id)
- );
- // Also remove any scrubbers from the timeline that use this media
- if (handleDeleteScrubbersByMediaBinId) {
- handleDeleteScrubbersByMediaBinId(item.id);
+ // For other media types, call the authenticated delete endpoint.
+ const assetId = item.id;
+ const res = await fetch(apiUrl(`/api/assets/${assetId}`, false, true), {
+ method: "DELETE",
+ credentials: "include",
+ });
+
+ if (res.ok) {
+ console.log(`Media deleted: ${item.name}`);
+ // On successful backend deletion, remove the item from the UI state.
+ setMediaBinItems((prev) => prev.filter((binItem) => binItem.id !== item.id));
+ if (handleDeleteScrubbersByMediaBinId) {
+ handleDeleteScrubbersByMediaBinId(item.id);
+ }
+ } else {
+ console.error("Failed to delete media:", await res.text());
}
- } else {
- console.error("Failed to delete media:", await res.text());
+ } catch (error) {
+ console.error("Error deleting media:", error);
}
- } catch (error) {
- console.error("Error deleting media:", error);
- }
- }, [handleDeleteScrubbersByMediaBinId]);
+ },
+ [handleDeleteScrubbersByMediaBinId],
+ );
const handleSplitAudio = useCallback(async (videoItem: MediaBinItem) => {
if (videoItem.mediaType !== "video") {
@@ -437,15 +423,12 @@ export const useMediaBin = (
}
// Clone via authenticated API (server will copy within out/ and record)
- const res = await fetch(
- apiUrl(`/api/assets/${videoItem.id}/clone`, false, true),
- {
- method: "POST",
- credentials: "include",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ suffix: "(Audio)" }),
- }
- );
+ const res = await fetch(apiUrl(`/api/assets/${videoItem.id}/clone`, false, true), {
+ method: "POST",
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ suffix: "(Audio)" }),
+ });
if (!res.ok) throw new Error("Failed to clone media file");
const cloneResult = await res.json();
@@ -471,9 +454,7 @@ export const useMediaBin = (
setMediaBinItems((prev) => [...prev, audioItem]);
setContextMenu(null); // Close context menu after action
- console.log(
- `Audio split successful: ${videoItem.name} -> ${audioItem.name}`
- );
+ console.log(`Audio split successful: ${videoItem.name} -> ${audioItem.name}`);
} catch (error) {
console.error("Error splitting audio:", error);
throw error;
@@ -481,17 +462,14 @@ export const useMediaBin = (
}, []);
// Handle right-click to show context menu
- const handleContextMenu = useCallback(
- (e: React.MouseEvent, item: MediaBinItem) => {
- e.preventDefault();
- setContextMenu({
- x: e.clientX,
- y: e.clientY,
- item,
- });
- },
- []
- );
+ const handleContextMenu = useCallback((e: React.MouseEvent, item: MediaBinItem) => {
+ e.preventDefault();
+ setContextMenu({
+ x: e.clientX,
+ y: e.clientY,
+ item,
+ });
+ }, []);
// Handle context menu actions
const handleDeleteFromContext = useCallback(async () => {
@@ -534,7 +512,7 @@ export const useMediaBin = (
groupped_scrubbers: groupedScrubber.groupped_scrubbers,
};
- setMediaBinItems(prev => [...prev, newItem]);
+ setMediaBinItems((prev) => [...prev, newItem]);
console.log("Added grouped scrubber to media bin:", newItem.name);
}, []);
diff --git a/app/hooks/useTimeline.ts b/app/hooks/useTimeline.ts
index 5534791..2be4f79 100644
--- a/app/hooks/useTimeline.ts
+++ b/app/hooks/useTimeline.ts
@@ -35,10 +35,7 @@ export const useTimeline = () => {
const [redoStack, setRedoStack] = useState([]);
const isApplyingHistoryRef = useRef(false);
- const deepClone = useCallback(
- (obj: T): T => JSON.parse(JSON.stringify(obj)),
- []
- );
+ const deepClone = useCallback((obj: T): T => JSON.parse(JSON.stringify(obj)), []);
const snapshotTimeline = useCallback(() => {
setUndoStack((prev) => {
@@ -239,7 +236,7 @@ export const useTimeline = () => {
}
return false;
},
- [timelineWidth]
+ [timelineWidth],
);
const handleAddTrack = useCallback(() => {
@@ -262,7 +259,7 @@ export const useTimeline = () => {
tracks: prev.tracks.filter((t) => t.id !== trackId),
}));
},
- [snapshotTimeline]
+ [snapshotTimeline],
);
const getAllScrubbers = useCallback(() => {
@@ -294,7 +291,7 @@ export const useTimeline = () => {
// Find current track index of the scrubber
const currentTrackIndex = prev.tracks.findIndex((track) =>
- track.scrubbers.some((scrubber) => scrubber.id === updatedScrubber.id)
+ track.scrubbers.some((scrubber) => scrubber.id === updatedScrubber.id),
);
if (currentTrackIndex === -1) return prev;
@@ -308,7 +305,7 @@ export const useTimeline = () => {
tracks: prev.tracks.map((track) => ({
...track,
scrubbers: track.scrubbers.map((scrubber) =>
- scrubber.id === updatedScrubber.id ? updatedScrubber : scrubber
+ scrubber.id === updatedScrubber.id ? updatedScrubber : scrubber,
),
})),
};
@@ -322,9 +319,7 @@ export const useTimeline = () => {
// Remove from current track
return {
...track,
- scrubbers: track.scrubbers.filter(
- (scrubber) => scrubber.id !== updatedScrubber.id
- ),
+ scrubbers: track.scrubbers.filter((scrubber) => scrubber.id !== updatedScrubber.id),
};
} else if (index === newTrackIndex) {
// Add to new track
@@ -338,7 +333,7 @@ export const useTimeline = () => {
};
});
},
- [deepClone]
+ [deepClone],
);
const handleDeleteScrubber = useCallback(
@@ -349,10 +344,7 @@ export const useTimeline = () => {
timeline.tracks.forEach((track) => {
track.transitions.forEach((transition) => {
- if (
- transition.leftScrubberId === scrubberId ||
- transition.rightScrubberId === scrubberId
- ) {
+ if (transition.leftScrubberId === scrubberId || transition.rightScrubberId === scrubberId) {
connectedTransitionIds.push(transition.id);
}
});
@@ -364,27 +356,19 @@ export const useTimeline = () => {
.map((track) => ({
...track,
// Remove the scrubber
- scrubbers: track.scrubbers.filter(
- (scrubber) => scrubber.id !== scrubberId
- ),
+ scrubbers: track.scrubbers.filter((scrubber) => scrubber.id !== scrubberId),
// Remove connected transitions
- transitions: track.transitions.filter(
- (transition) => !connectedTransitionIds.includes(transition.id)
- ),
+ transitions: track.transitions.filter((transition) => !connectedTransitionIds.includes(transition.id)),
}))
.map((track) => ({
...track,
// Clean up transition references in remaining scrubbers
scrubbers: track.scrubbers.map((scrubber) => ({
...scrubber,
- left_transition_id: connectedTransitionIds.includes(
- scrubber.left_transition_id || ""
- )
+ left_transition_id: connectedTransitionIds.includes(scrubber.left_transition_id || "")
? null
: scrubber.left_transition_id,
- right_transition_id: connectedTransitionIds.includes(
- scrubber.right_transition_id || ""
- )
+ right_transition_id: connectedTransitionIds.includes(scrubber.right_transition_id || "")
? null
: scrubber.right_transition_id,
})),
@@ -394,14 +378,15 @@ export const useTimeline = () => {
// Show feedback message
if (connectedTransitionIds.length > 0) {
toast.success(
- `Scrubber and ${connectedTransitionIds.length} connected transition${connectedTransitionIds.length > 1 ? "s" : ""
- } deleted`
+ `Scrubber and ${connectedTransitionIds.length} connected transition${
+ connectedTransitionIds.length > 1 ? "s" : ""
+ } deleted`,
);
} else {
toast.success("Scrubber deleted");
}
},
- [timeline, snapshotTimeline]
+ [timeline, snapshotTimeline],
);
const handleDeleteScrubbersByMediaBinId = useCallback(
@@ -436,27 +421,19 @@ export const useTimeline = () => {
.map((track) => ({
...track,
// Remove scrubbers with matching media bin ID
- scrubbers: track.scrubbers.filter(
- (scrubber) => scrubber.sourceMediaBinId !== mediaBinId
- ),
+ scrubbers: track.scrubbers.filter((scrubber) => scrubber.sourceMediaBinId !== mediaBinId),
// Remove connected transitions
- transitions: track.transitions.filter(
- (transition) => !connectedTransitionIds.includes(transition.id)
- ),
+ transitions: track.transitions.filter((transition) => !connectedTransitionIds.includes(transition.id)),
}))
.map((track) => ({
...track,
// Clean up transition references in remaining scrubbers
scrubbers: track.scrubbers.map((scrubber) => ({
...scrubber,
- left_transition_id: connectedTransitionIds.includes(
- scrubber.left_transition_id || ""
- )
+ left_transition_id: connectedTransitionIds.includes(scrubber.left_transition_id || "")
? null
: scrubber.left_transition_id,
- right_transition_id: connectedTransitionIds.includes(
- scrubber.right_transition_id || ""
- )
+ right_transition_id: connectedTransitionIds.includes(scrubber.right_transition_id || "")
? null
: scrubber.right_transition_id,
})),
@@ -467,141 +444,133 @@ export const useTimeline = () => {
if (scrubbersToDelete.length > 0) {
if (connectedTransitionIds.length > 0) {
toast.success(
- `${scrubbersToDelete.length} scrubber${scrubbersToDelete.length > 1 ? "s" : ""
- } and ${connectedTransitionIds.length} connected transition${connectedTransitionIds.length > 1 ? "s" : ""
- } deleted`
+ `${scrubbersToDelete.length} scrubber${
+ scrubbersToDelete.length > 1 ? "s" : ""
+ } and ${connectedTransitionIds.length} connected transition${
+ connectedTransitionIds.length > 1 ? "s" : ""
+ } deleted`,
);
} else {
- toast.success(
- `${scrubbersToDelete.length} scrubber${scrubbersToDelete.length > 1 ? "s" : ""
- } deleted`
- );
+ toast.success(`${scrubbersToDelete.length} scrubber${scrubbersToDelete.length > 1 ? "s" : ""} deleted`);
}
}
},
- [timeline, snapshotTimeline]
+ [timeline, snapshotTimeline],
);
- const handleAddScrubberToTrack = useCallback(
- (trackId: string, newScrubber: ScrubberState) => {
- console.log("Adding scrubber to track", trackId, newScrubber);
- setTimeline((prev) => ({
- ...prev,
- tracks: prev.tracks.map((track) =>
- track.id === trackId
- ? { ...track, scrubbers: [...track.scrubbers, newScrubber] }
- : track
- ),
- }));
- },
- []
- );
+ const handleAddScrubberToTrack = useCallback((trackId: string, newScrubber: ScrubberState): string => {
+ console.log("Adding scrubber to track", trackId, newScrubber);
+ setTimeline((prev) => ({
+ ...prev,
+ tracks: prev.tracks.map((track) =>
+ track.id === trackId ? { ...track, scrubbers: [...track.scrubbers, newScrubber] } : track,
+ ),
+ }));
+ return newScrubber.id;
+ }, []);
// Helper function to recursively generate new UUIDs for grouped scrubbers and their transitions
- const generateNewUUIDsForGroupedScrubbers = useCallback((
- scrubbers: ScrubberState[] | null,
- allTransitions?: { [id: string]: Transition }
- ): { scrubbers: ScrubberState[] | null, clonedTransitions: Transition[] } => {
- if (!scrubbers) return { scrubbers: null, clonedTransitions: [] };
-
- const transitionIdMapping: { [oldId: string]: string } = {};
- const scrubberIdMapping: { [oldId: string]: string } = {};
- const clonedTransitions: Transition[] = [];
-
- // First pass: collect all transition IDs and scrubber IDs that need to be cloned
- const collectIds = (scrubberList: ScrubberState[]) => {
- for (const scrubber of scrubberList) {
- scrubberIdMapping[scrubber.id] = generateUUID();
-
- if (scrubber.left_transition_id) {
- transitionIdMapping[scrubber.left_transition_id] = generateUUID();
- }
- if (scrubber.right_transition_id) {
- transitionIdMapping[scrubber.right_transition_id] = generateUUID();
- }
- if (scrubber.groupped_scrubbers) {
- collectIds(scrubber.groupped_scrubbers);
+ const generateNewUUIDsForGroupedScrubbers = useCallback(
+ (
+ scrubbers: ScrubberState[] | null,
+ allTransitions?: { [id: string]: Transition },
+ ): { scrubbers: ScrubberState[] | null; clonedTransitions: Transition[] } => {
+ if (!scrubbers) return { scrubbers: null, clonedTransitions: [] };
+
+ const transitionIdMapping: { [oldId: string]: string } = {};
+ const scrubberIdMapping: { [oldId: string]: string } = {};
+ const clonedTransitions: Transition[] = [];
+
+ // First pass: collect all transition IDs and scrubber IDs that need to be cloned
+ const collectIds = (scrubberList: ScrubberState[]) => {
+ for (const scrubber of scrubberList) {
+ scrubberIdMapping[scrubber.id] = generateUUID();
+
+ if (scrubber.left_transition_id) {
+ transitionIdMapping[scrubber.left_transition_id] = generateUUID();
+ }
+ if (scrubber.right_transition_id) {
+ transitionIdMapping[scrubber.right_transition_id] = generateUUID();
+ }
+ if (scrubber.groupped_scrubbers) {
+ collectIds(scrubber.groupped_scrubbers);
+ }
}
- }
- };
+ };
- collectIds(scrubbers);
-
- // Clone transitions with new IDs and updated scrubber references if we have access to all transitions
- if (allTransitions) {
- for (const [oldTransitionId, newTransitionId] of Object.entries(transitionIdMapping)) {
- const originalTransition = allTransitions[oldTransitionId];
- if (originalTransition) {
- clonedTransitions.push({
- ...originalTransition,
- id: newTransitionId,
- leftScrubberId: originalTransition.leftScrubberId ?
- scrubberIdMapping[originalTransition.leftScrubberId] || originalTransition.leftScrubberId : null,
- rightScrubberId: originalTransition.rightScrubberId ?
- scrubberIdMapping[originalTransition.rightScrubberId] || originalTransition.rightScrubberId : null,
- });
+ collectIds(scrubbers);
+
+ // Clone transitions with new IDs and updated scrubber references if we have access to all transitions
+ if (allTransitions) {
+ for (const [oldTransitionId, newTransitionId] of Object.entries(transitionIdMapping)) {
+ const originalTransition = allTransitions[oldTransitionId];
+ if (originalTransition) {
+ clonedTransitions.push({
+ ...originalTransition,
+ id: newTransitionId,
+ leftScrubberId: originalTransition.leftScrubberId
+ ? scrubberIdMapping[originalTransition.leftScrubberId] || originalTransition.leftScrubberId
+ : null,
+ rightScrubberId: originalTransition.rightScrubberId
+ ? scrubberIdMapping[originalTransition.rightScrubberId] || originalTransition.rightScrubberId
+ : null,
+ });
+ }
}
}
- }
- // Second pass: update scrubbers with new IDs
- const updateScrubbers = (scrubberList: ScrubberState[]): ScrubberState[] => {
- return scrubberList.map((scrubber) => {
- const result = generateNewUUIDsForGroupedScrubbers(scrubber.groupped_scrubbers, allTransitions);
- clonedTransitions.push(...result.clonedTransitions);
+ // Second pass: update scrubbers with new IDs
+ const updateScrubbers = (scrubberList: ScrubberState[]): ScrubberState[] => {
+ return scrubberList.map((scrubber) => {
+ const result = generateNewUUIDsForGroupedScrubbers(scrubber.groupped_scrubbers, allTransitions);
+ clonedTransitions.push(...result.clonedTransitions);
- return {
- ...scrubber,
- id: scrubberIdMapping[scrubber.id],
- left_transition_id: scrubber.left_transition_id ? transitionIdMapping[scrubber.left_transition_id] || null : null,
- right_transition_id: scrubber.right_transition_id ? transitionIdMapping[scrubber.right_transition_id] || null : null,
- groupped_scrubbers: result.scrubbers,
- };
- });
- };
+ return {
+ ...scrubber,
+ id: scrubberIdMapping[scrubber.id],
+ left_transition_id: scrubber.left_transition_id
+ ? transitionIdMapping[scrubber.left_transition_id] || null
+ : null,
+ right_transition_id: scrubber.right_transition_id
+ ? transitionIdMapping[scrubber.right_transition_id] || null
+ : null,
+ groupped_scrubbers: result.scrubbers,
+ };
+ });
+ };
- return {
- scrubbers: updateScrubbers(scrubbers),
- clonedTransitions: clonedTransitions,
- };
- }, []);
+ return {
+ scrubbers: updateScrubbers(scrubbers),
+ clonedTransitions: clonedTransitions,
+ };
+ },
+ [],
+ );
const handleDropOnTrack = useCallback(
- (item: MediaBinItem, trackId: string, dropLeftPx: number) => {
+ (item: MediaBinItem, trackId: string, dropLeftPx: number): string => {
snapshotTimeline();
- console.log(
- "Dropped",
- item.name,
- "on track",
- trackId,
- "at",
- dropLeftPx,
- "px"
- );
+ console.log("Dropped", item.name, "on track", trackId, "at", dropLeftPx, "px");
const pixelsPerSecond = getPixelsPerSecond();
let widthPx = item.mediaType === "text" ? 80 : 150;
- if ((item.mediaType === "video" || item.mediaType === "audio" || item.mediaType === "groupped_scrubber") && item.durationInSeconds) {
+ if (
+ (item.mediaType === "video" || item.mediaType === "audio" || item.mediaType === "groupped_scrubber") &&
+ item.durationInSeconds
+ ) {
widthPx = item.durationInSeconds * pixelsPerSecond;
} else if (item.mediaType === "image") {
widthPx = 100;
}
widthPx = Math.max(20, widthPx);
- const targetTrackIndex = timeline.tracks.findIndex(
- (t) => t.id === trackId
- );
+ const targetTrackIndex = timeline.tracks.findIndex((t) => t.id === trackId);
if (targetTrackIndex === -1) return;
// For text elements, provide default dimensions if they're 0
const playerWidth =
item.mediaType === "text" && item.media_width === 0
- ? Math.max(
- 200,
- (item.text?.textContent?.length || 10) *
- (item.text?.fontSize || 48) *
- 0.6
- )
+ ? Math.max(200, (item.text?.textContent?.length || 10) * (item.text?.fontSize || 48) * 0.6)
: item.media_width;
const playerHeight =
item.mediaType === "text" && item.media_height === 0
@@ -610,9 +579,10 @@ export const useTimeline = () => {
// Generate new UUIDs for grouped scrubbers and their transitions to prevent collisions when ungrouping
const allTransitions = getAllTransitions();
- const groupedResult = item.mediaType === "groupped_scrubber"
- ? generateNewUUIDsForGroupedScrubbers(item.groupped_scrubbers, allTransitions)
- : { scrubbers: item.groupped_scrubbers, clonedTransitions: [] };
+ const groupedResult =
+ item.mediaType === "groupped_scrubber"
+ ? generateNewUUIDsForGroupedScrubbers(item.groupped_scrubbers, allTransitions)
+ : { scrubbers: item.groupped_scrubbers, clonedTransitions: [] };
const processedGroupedScrubbers = groupedResult.scrubbers;
const clonedTransitions = groupedResult.clonedTransitions;
@@ -660,24 +630,26 @@ export const useTimeline = () => {
tracks: prev.tracks.map((track) =>
track.id === trackId
? {
- ...track,
- scrubbers: [...track.scrubbers, newScrubber],
- transitions: [...track.transitions, ...clonedTransitions]
- }
- : track
+ ...track,
+ scrubbers: [...track.scrubbers, newScrubber],
+ transitions: [...track.transitions, ...clonedTransitions],
+ }
+ : track,
),
}));
+ return newScrubber.id;
} else {
- handleAddScrubberToTrack(trackId, newScrubber);
+ return handleAddScrubberToTrack(trackId, newScrubber);
}
- }, [
- timeline.tracks,
- handleAddScrubberToTrack,
- getPixelsPerSecond,
- generateNewUUIDsForGroupedScrubbers,
- getAllTransitions,
- snapshotTimeline,
- ]
+ },
+ [
+ timeline.tracks,
+ handleAddScrubberToTrack,
+ getPixelsPerSecond,
+ generateNewUUIDsForGroupedScrubbers,
+ getAllTransitions,
+ snapshotTimeline,
+ ],
);
const handleSplitScrubberAtRuler = useCallback(
@@ -692,17 +664,14 @@ export const useTimeline = () => {
// Find the selected scrubber
const allScrubbers = timeline.tracks.flatMap((track) => track.scrubbers);
- const selectedScrubber = allScrubbers.find(
- (scrubber) => scrubber.id === selectedScrubberId
- );
+ const selectedScrubber = allScrubbers.find((scrubber) => scrubber.id === selectedScrubberId);
if (!selectedScrubber) {
return 0; // Selected scrubber not found
}
const startTime = selectedScrubber.left / pixelsPerSecond;
- const endTime =
- (selectedScrubber.left + selectedScrubber.width) / pixelsPerSecond;
+ const endTime = (selectedScrubber.left + selectedScrubber.width) / pixelsPerSecond;
// Check if split time is within the selected scrubber (excluding edges)
if (splitTimeInSeconds <= startTime || splitTimeInSeconds >= endTime) {
@@ -762,7 +731,7 @@ export const useTimeline = () => {
return 1; // One scrubber was split
},
- [timeline, getPixelsPerSecond, snapshotTimeline]
+ [timeline, getPixelsPerSecond, snapshotTimeline],
);
// Transition management functions
@@ -771,18 +740,14 @@ export const useTimeline = () => {
leftScrubberId: string | null,
rightScrubberId: string | null,
transition: Transition,
- trackId: string
+ trackId: string,
): { valid: boolean; error?: string } => {
const track = timeline.tracks.find((t) => t.id === trackId);
if (!track) return { valid: false, error: "Track not found" };
// Get scrubbers
- const leftScrubber = leftScrubberId
- ? track.scrubbers.find((s) => s.id === leftScrubberId)
- : null;
- const rightScrubber = rightScrubberId
- ? track.scrubbers.find((s) => s.id === rightScrubberId)
- : null;
+ const leftScrubber = leftScrubberId ? track.scrubbers.find((s) => s.id === leftScrubberId) : null;
+ const rightScrubber = rightScrubberId ? track.scrubbers.find((s) => s.id === rightScrubberId) : null;
// Rule 1: Transition can't be longer than adjacent sequences
if (leftScrubber) {
@@ -796,8 +761,7 @@ export const useTimeline = () => {
}
if (rightScrubber) {
- const rightDuration =
- (rightScrubber.width / getPixelsPerSecond()) * FPS;
+ const rightDuration = (rightScrubber.width / getPixelsPerSecond()) * FPS;
if (transition.durationInFrames > rightDuration) {
return {
valid: false,
@@ -807,10 +771,7 @@ export const useTimeline = () => {
}
// Rule 2: No two transitions next to each other
- if (
- leftScrubber?.right_transition_id &&
- rightScrubber?.left_transition_id
- ) {
+ if (leftScrubber?.right_transition_id && rightScrubber?.left_transition_id) {
return {
valid: false,
error: "Cannot place transitions next to each other",
@@ -824,7 +785,7 @@ export const useTimeline = () => {
return { valid: true };
},
- [timeline, getPixelsPerSecond]
+ [timeline, getPixelsPerSecond],
);
const getConnectedElements = useCallback(
@@ -842,16 +803,10 @@ export const useTimeline = () => {
// Check scrubbers
for (const scrubber of track.scrubbers) {
if (scrubber.id === currentId) {
- if (
- scrubber.left_transition_id &&
- !connected.has(scrubber.left_transition_id)
- ) {
+ if (scrubber.left_transition_id && !connected.has(scrubber.left_transition_id)) {
toProcess.push(scrubber.left_transition_id);
}
- if (
- scrubber.right_transition_id &&
- !connected.has(scrubber.right_transition_id)
- ) {
+ if (scrubber.right_transition_id && !connected.has(scrubber.right_transition_id)) {
toProcess.push(scrubber.right_transition_id);
}
}
@@ -860,16 +815,10 @@ export const useTimeline = () => {
// Check transitions
for (const transition of track.transitions) {
if (transition.id === currentId) {
- if (
- transition.leftScrubberId &&
- !connected.has(transition.leftScrubberId)
- ) {
+ if (transition.leftScrubberId && !connected.has(transition.leftScrubberId)) {
toProcess.push(transition.leftScrubberId);
}
- if (
- transition.rightScrubberId &&
- !connected.has(transition.rightScrubberId)
- ) {
+ if (transition.rightScrubberId && !connected.has(transition.rightScrubberId)) {
toProcess.push(transition.rightScrubberId);
}
}
@@ -879,7 +828,7 @@ export const useTimeline = () => {
return Array.from(connected);
},
- [timeline]
+ [timeline],
);
const handleAddTransitionToTrack = useCallback(
@@ -893,9 +842,7 @@ export const useTimeline = () => {
// Find scrubbers at or near the drop position
const scrubbers = track.scrubbers
- .filter(
- (s) => s.y === timeline.tracks.findIndex((t) => t.id === trackId)
- )
+ .filter((s) => s.y === timeline.tracks.findIndex((t) => t.id === trackId))
.sort((a, b) => a.left - b.left);
let leftScrubber: ScrubberState | null = null;
@@ -913,8 +860,7 @@ export const useTimeline = () => {
const prevScrubber = scrubbers[i - 1] || null;
if (prevScrubber) {
// Check gap to previous scrubber
- const gap =
- scrubber.left - (prevScrubber.left + prevScrubber.width);
+ const gap = scrubber.left - (prevScrubber.left + prevScrubber.width);
if (gap <= 10) {
// Within snap distance
// Create transition between previous and current scrubber
@@ -973,19 +919,13 @@ export const useTimeline = () => {
}
// Validate audio scrubbers
- if (
- leftScrubber?.mediaType === "audio" ||
- rightScrubber?.mediaType === "audio"
- ) {
+ if (leftScrubber?.mediaType === "audio" || rightScrubber?.mediaType === "audio") {
toast.error("Audio scrubbers cannot have transitions");
return;
}
// Validate grouped scrubbers
- if (
- leftScrubber?.mediaType === "groupped_scrubber" ||
- rightScrubber?.mediaType === "groupped_scrubber"
- ) {
+ if (leftScrubber?.mediaType === "groupped_scrubber" || rightScrubber?.mediaType === "groupped_scrubber") {
toast.error("Grouped scrubbers cannot have transitions");
return;
}
@@ -1002,7 +942,7 @@ export const useTimeline = () => {
leftScrubber?.id || null,
rightScrubber?.id || null,
updatedTransition,
- trackId
+ trackId,
);
if (!validation.valid) {
@@ -1012,8 +952,7 @@ export const useTimeline = () => {
// Calculate the overlap distance needed for the transition
const pixelsPerSecond = getPixelsPerSecond();
- const transitionWidthPx =
- (updatedTransition.durationInFrames / 30) * pixelsPerSecond;
+ const transitionWidthPx = (updatedTransition.durationInFrames / 30) * pixelsPerSecond;
// Define snap distance threshold (same as in TimelineTracks.tsx)
const SNAP_DISTANCE = 10;
@@ -1073,12 +1012,9 @@ export const useTimeline = () => {
}));
toast.success("Transition added successfully");
- }, [
- getPixelsPerSecond,
- timeline.tracks,
- validateTransitionPlacement,
- snapshotTimeline,
- ]);
+ },
+ [getPixelsPerSecond, timeline.tracks, validateTransitionPlacement, snapshotTimeline],
+ );
const handleDeleteTransition = useCallback(
(transitionId: string) => {
@@ -1089,9 +1025,7 @@ export const useTimeline = () => {
let targetTrackId: string | null = null;
for (const track of prev.tracks) {
- const foundTransition = track.transitions.find(
- (t) => t.id === transitionId
- );
+ const foundTransition = track.transitions.find((t) => t.id === transitionId);
if (foundTransition) {
transitionToDelete = foundTransition;
targetTrackId = track.id;
@@ -1114,29 +1048,20 @@ export const useTimeline = () => {
const targetTrack = prev.tracks.find((t) => t.id === targetTrackId);
if (targetTrack && transitionToDelete) {
const pixelsPerSecond = getPixelsPerSecond();
- const transitionWidthPx =
- (transitionToDelete.durationInFrames / FPS) * pixelsPerSecond;
+ const transitionWidthPx = (transitionToDelete.durationInFrames / FPS) * pixelsPerSecond;
const SNAP_DISTANCE = 10;
- rightScrubber =
- targetTrack.scrubbers.find(
- (s) => s.id === transitionToDelete.rightScrubberId
- ) || null;
- leftScrubber =
- targetTrack.scrubbers.find(
- (s) => s.id === transitionToDelete.leftScrubberId
- ) || null;
+ rightScrubber = targetTrack.scrubbers.find((s) => s.id === transitionToDelete.rightScrubberId) || null;
+ leftScrubber = targetTrack.scrubbers.find((s) => s.id === transitionToDelete.leftScrubberId) || null;
if (rightScrubber && leftScrubber) {
- const currentGap =
- rightScrubber.left - (leftScrubber.left + leftScrubber.width);
+ const currentGap = rightScrubber.left - (leftScrubber.left + leftScrubber.width);
const originalGap = currentGap + transitionWidthPx;
if (originalGap <= SNAP_DISTANCE) {
shouldRestorePosition = true;
- rightScrubberNewLeft =
- leftScrubber.left + leftScrubber.width + originalGap;
+ rightScrubberNewLeft = leftScrubber.left + leftScrubber.width + originalGap;
movementDistance = rightScrubberNewLeft - rightScrubber.left;
}
}
@@ -1148,22 +1073,15 @@ export const useTimeline = () => {
return {
...track,
- transitions: isTargetTrack
- ? track.transitions.filter((t) => t.id !== transitionId)
- : track.transitions,
+ transitions: isTargetTrack ? track.transitions.filter((t) => t.id !== transitionId) : track.transitions,
scrubbers: track.scrubbers.map((scrubber) => {
// Always clear transition references
const baseScrubber: ScrubberState = {
...scrubber,
- left_transition_id:
- scrubber.left_transition_id === transitionId
- ? null
- : scrubber.left_transition_id,
+ left_transition_id: scrubber.left_transition_id === transitionId ? null : scrubber.left_transition_id,
right_transition_id:
- scrubber.right_transition_id === transitionId
- ? null
- : scrubber.right_transition_id,
+ scrubber.right_transition_id === transitionId ? null : scrubber.right_transition_id,
};
// Restore right scrubber position if snapped
@@ -1177,19 +1095,10 @@ export const useTimeline = () => {
}
// Shift later scrubbers on the same track if needed
- if (
- isTargetTrack &&
- rightScrubber &&
- scrubber.id !== rightScrubber.id &&
- movementDistance > 0
- ) {
- const rightScrubberOriginalEnd =
- rightScrubber.left + rightScrubber.width;
+ if (isTargetTrack && rightScrubber && scrubber.id !== rightScrubber.id && movementDistance > 0) {
+ const rightScrubberOriginalEnd = rightScrubber.left + rightScrubber.width;
- if (
- scrubber.y === rightScrubber.y &&
- scrubber.left >= rightScrubberOriginalEnd
- ) {
+ if (scrubber.y === rightScrubber.y && scrubber.left >= rightScrubberOriginalEnd) {
return {
...baseScrubber,
left: scrubber.left + movementDistance,
@@ -1207,25 +1116,20 @@ export const useTimeline = () => {
toast.success("Transition deleted");
},
- [getPixelsPerSecond, snapshotTimeline]
+ [getPixelsPerSecond, snapshotTimeline],
);
-
// Check if there's a transition between two scrubbers that allows overlap
const hasTransitionBetween = useCallback(
(scrubber1Id: string, scrubber2Id: string) => {
- const allTransitions = timeline.tracks.flatMap(
- (track) => track.transitions
- );
+ const allTransitions = timeline.tracks.flatMap((track) => track.transitions);
return allTransitions.some(
(transition) =>
- (transition.leftScrubberId === scrubber1Id &&
- transition.rightScrubberId === scrubber2Id) ||
- (transition.leftScrubberId === scrubber2Id &&
- transition.rightScrubberId === scrubber1Id)
+ (transition.leftScrubberId === scrubber1Id && transition.rightScrubberId === scrubber2Id) ||
+ (transition.leftScrubberId === scrubber2Id && transition.rightScrubberId === scrubber1Id),
);
},
- [timeline]
+ [timeline],
);
// Check collision with track awareness - allow overlap if transition exists
@@ -1250,20 +1154,14 @@ export const useTimeline = () => {
return hasOverlap;
});
},
- [getAllScrubbers, hasTransitionBetween]
+ [getAllScrubbers, hasTransitionBetween],
);
// Handle collision detection and smart positioning
const handleCollisionDetection = useCallback(
- (
- updatedScrubber: ScrubberState,
- originalScrubber: ScrubberState,
- timelineWidth: number
- ) => {
+ (updatedScrubber: ScrubberState, originalScrubber: ScrubberState, timelineWidth: number) => {
const allScrubbers = getAllScrubbers();
- const otherScrubbers = allScrubbers.filter(
- (s) => s.id !== updatedScrubber.id
- );
+ const otherScrubbers = allScrubbers.filter((s) => s.id !== updatedScrubber.id);
// Find colliding scrubbers on the same track
const collidingScrubbers = otherScrubbers.filter((other) => {
@@ -1308,12 +1206,9 @@ export const useTimeline = () => {
mouseCenter < collidingCenter
? { ...updatedScrubber, left: Math.max(0, snapToLeft) }
: {
- ...updatedScrubber,
- left: Math.min(
- snapToRight,
- timelineWidth - updatedScrubber.width
- ),
- };
+ ...updatedScrubber,
+ left: Math.min(snapToRight, timelineWidth - updatedScrubber.width),
+ };
if (!checkCollisionWithTrack(preferredScrubber, updatedScrubber.id)) {
return preferredScrubber;
@@ -1322,12 +1217,9 @@ export const useTimeline = () => {
const alternateScrubber =
mouseCenter < collidingCenter
? {
- ...updatedScrubber,
- left: Math.min(
- snapToRight,
- timelineWidth - updatedScrubber.width
- ),
- }
+ ...updatedScrubber,
+ left: Math.min(snapToRight, timelineWidth - updatedScrubber.width),
+ }
: { ...updatedScrubber, left: Math.max(0, snapToLeft) };
if (!checkCollisionWithTrack(alternateScrubber, updatedScrubber.id)) {
@@ -1340,7 +1232,7 @@ export const useTimeline = () => {
return updatedScrubber;
},
- [getAllScrubbers, checkCollisionWithTrack]
+ [getAllScrubbers, checkCollisionWithTrack],
);
const handleUpdateScrubberWithLocking = useCallback(
@@ -1351,9 +1243,7 @@ export const useTimeline = () => {
}
const connectedElements = getConnectedElements(updatedScrubber.id);
const scrubberConnected = connectedElements.filter((id) =>
- timeline.tracks.some((track) =>
- track.scrubbers.some((s) => s.id === id)
- )
+ timeline.tracks.some((track) => track.scrubbers.some((s) => s.id === id)),
);
// Check if this scrubber is connected to transitions (more than just itself)
@@ -1361,9 +1251,7 @@ export const useTimeline = () => {
if (isConnectedToTransitions) {
// Handle collision detection for connected scrubbers as a group
- const originalScrubber = getAllScrubbers().find(
- (s) => s.id === updatedScrubber.id
- );
+ const originalScrubber = getAllScrubbers().find((s) => s.id === updatedScrubber.id);
if (!originalScrubber) return;
const offsetX = updatedScrubber.left - originalScrubber.left;
@@ -1371,48 +1259,36 @@ export const useTimeline = () => {
// Calculate new positions for all connected scrubbers
const allScrubbers = getAllScrubbers();
- const connectedScrubbers = allScrubbers.filter((s) =>
- scrubberConnected.includes(s.id)
- );
- const updatedConnectedScrubbers = connectedScrubbers.map(
- (scrubber) => ({
- ...scrubber,
- left: scrubber.left + offsetX,
- y: scrubber.y + offsetY,
- })
- );
+ const connectedScrubbers = allScrubbers.filter((s) => scrubberConnected.includes(s.id));
+ const updatedConnectedScrubbers = connectedScrubbers.map((scrubber) => ({
+ ...scrubber,
+ left: scrubber.left + offsetX,
+ y: scrubber.y + offsetY,
+ }));
// Check if any of the connected scrubbers would collide with non-connected scrubbers
- const hasCollision = updatedConnectedScrubbers.some(
- (updatedConnectedScrubber) => {
- return allScrubbers.some((other) => {
- // Skip if other scrubber is also in the connected group
- if (scrubberConnected.includes(other.id)) return false;
- // Skip if on different tracks
- if (other.y !== updatedConnectedScrubber.y) return false;
-
- const otherStart = other.left;
- const otherEnd = other.left + other.width;
- const newStart = updatedConnectedScrubber.left;
- const newEnd =
- updatedConnectedScrubber.left + updatedConnectedScrubber.width;
-
- const hasOverlap = !(
- newEnd <= otherStart || newStart >= otherEnd
- );
-
- // If there's overlap, check if there's a transition that allows it
- if (
- hasOverlap &&
- hasTransitionBetween(updatedConnectedScrubber.id, other.id)
- ) {
- return false; // Allow overlap due to transition
- }
+ const hasCollision = updatedConnectedScrubbers.some((updatedConnectedScrubber) => {
+ return allScrubbers.some((other) => {
+ // Skip if other scrubber is also in the connected group
+ if (scrubberConnected.includes(other.id)) return false;
+ // Skip if on different tracks
+ if (other.y !== updatedConnectedScrubber.y) return false;
+
+ const otherStart = other.left;
+ const otherEnd = other.left + other.width;
+ const newStart = updatedConnectedScrubber.left;
+ const newEnd = updatedConnectedScrubber.left + updatedConnectedScrubber.width;
+
+ const hasOverlap = !(newEnd <= otherStart || newStart >= otherEnd);
+
+ // If there's overlap, check if there's a transition that allows it
+ if (hasOverlap && hasTransitionBetween(updatedConnectedScrubber.id, other.id)) {
+ return false; // Allow overlap due to transition
+ }
- return hasOverlap;
- });
- }
- );
+ return hasOverlap;
+ });
+ });
// Only update if there's no collision
if (!hasCollision) {
@@ -1436,16 +1312,10 @@ export const useTimeline = () => {
// If there is a collision, don't move the connected scrubbers (they stay in place)
} else {
// Run collision detection for standalone scrubbers
- const originalScrubber = getAllScrubbers().find(
- (s) => s.id === updatedScrubber.id
- );
+ const originalScrubber = getAllScrubbers().find((s) => s.id === updatedScrubber.id);
if (!originalScrubber) return;
- const finalScrubber = handleCollisionDetection(
- updatedScrubber,
- originalScrubber,
- timelineWidth
- );
+ const finalScrubber = handleCollisionDetection(updatedScrubber, originalScrubber, timelineWidth);
handleUpdateScrubber(finalScrubber);
}
},
@@ -1457,87 +1327,87 @@ export const useTimeline = () => {
handleCollisionDetection,
timelineWidth,
hasTransitionBetween,
- ]
+ ],
);
// Group multiple scrubbers into a single grouped scrubber
- const handleGroupScrubbers = useCallback((scrubberIds: string[]) => {
- if (scrubberIds.length < 2) return;
+ const handleGroupScrubbers = useCallback(
+ (scrubberIds: string[]) => {
+ if (scrubberIds.length < 2) return;
- setTimeline((prev) => {
- // Find all scrubbers to group and their tracks
- const scrubbersToGroup: ScrubberState[] = [];
- let targetTrackIndex = -1;
-
- for (const track of prev.tracks) {
- for (const scrubber of track.scrubbers) {
- if (scrubberIds.includes(scrubber.id)) {
- scrubbersToGroup.push(scrubber);
- if (targetTrackIndex === -1) {
- targetTrackIndex = prev.tracks.indexOf(track);
+ setTimeline((prev) => {
+ // Find all scrubbers to group and their tracks
+ const scrubbersToGroup: ScrubberState[] = [];
+ let targetTrackIndex = -1;
+
+ for (const track of prev.tracks) {
+ for (const scrubber of track.scrubbers) {
+ if (scrubberIds.includes(scrubber.id)) {
+ scrubbersToGroup.push(scrubber);
+ if (targetTrackIndex === -1) {
+ targetTrackIndex = prev.tracks.indexOf(track);
+ }
}
}
}
- }
-
- scrubbersToGroup.sort((a, b) => a.left - b.left);
- // Calculate grouped scrubber bounds
- const leftmost = Math.min(...scrubbersToGroup.map(s => s.left));
- const rightmost = Math.max(...scrubbersToGroup.map(s => s.left + s.width));
- const topmost = Math.min(...scrubbersToGroup.map(s => s.y || 0));
-
- // Create grouped scrubber
- const groupedScrubber: ScrubberState = {
- id: generateUUID(),
- mediaType: "groupped_scrubber",
- mediaUrlLocal: null,
- mediaUrlRemote: null,
- media_width: rightmost - leftmost,
- media_height: 60,
- text: null,
- groupped_scrubbers: scrubbersToGroup,
- left_transition_id: null,
- right_transition_id: null,
- name: `Group: ${scrubbersToGroup.map(scrubber => scrubber.name).join(' + ')}`,
- durationInSeconds: (rightmost - leftmost) / (PIXELS_PER_SECOND * zoomLevel),
- uploadProgress: null,
- isUploading: false,
- left: leftmost,
- y: topmost,
- width: rightmost - leftmost,
- sourceMediaBinId: generateUUID(),
- left_player: 0,
- top_player: 0,
- width_player: 0,
- height_player: 0,
- is_dragging: false,
- trimBefore: null,
- trimAfter: null,
- };
+ scrubbersToGroup.sort((a, b) => a.left - b.left);
+
+ // Calculate grouped scrubber bounds
+ const leftmost = Math.min(...scrubbersToGroup.map((s) => s.left));
+ const rightmost = Math.max(...scrubbersToGroup.map((s) => s.left + s.width));
+ const topmost = Math.min(...scrubbersToGroup.map((s) => s.y || 0));
+
+ // Create grouped scrubber
+ const groupedScrubber: ScrubberState = {
+ id: generateUUID(),
+ mediaType: "groupped_scrubber",
+ mediaUrlLocal: null,
+ mediaUrlRemote: null,
+ media_width: rightmost - leftmost,
+ media_height: 60,
+ text: null,
+ groupped_scrubbers: scrubbersToGroup,
+ left_transition_id: null,
+ right_transition_id: null,
+ name: `Group: ${scrubbersToGroup.map((scrubber) => scrubber.name).join(" + ")}`,
+ durationInSeconds: (rightmost - leftmost) / (PIXELS_PER_SECOND * zoomLevel),
+ uploadProgress: null,
+ isUploading: false,
+ left: leftmost,
+ y: topmost,
+ width: rightmost - leftmost,
+ sourceMediaBinId: generateUUID(),
+ left_player: 0,
+ top_player: 0,
+ width_player: 0,
+ height_player: 0,
+ is_dragging: false,
+ trimBefore: null,
+ trimAfter: null,
+ };
- // Remove individual scrubbers and add grouped scrubber
- return {
- ...prev,
- tracks: prev.tracks.map((track, index) => {
- if (index === targetTrackIndex) {
- return {
- ...track,
- scrubbers: [
- ...track.scrubbers.filter(s => !scrubberIds.includes(s.id)),
- groupedScrubber
- ]
- };
- } else {
- return {
- ...track,
- scrubbers: track.scrubbers.filter(s => !scrubberIds.includes(s.id))
- };
- }
- }),
- };
- });
- }, [zoomLevel]);
+ // Remove individual scrubbers and add grouped scrubber
+ return {
+ ...prev,
+ tracks: prev.tracks.map((track, index) => {
+ if (index === targetTrackIndex) {
+ return {
+ ...track,
+ scrubbers: [...track.scrubbers.filter((s) => !scrubberIds.includes(s.id)), groupedScrubber],
+ };
+ } else {
+ return {
+ ...track,
+ scrubbers: track.scrubbers.filter((s) => !scrubberIds.includes(s.id)),
+ };
+ }
+ }),
+ };
+ });
+ },
+ [zoomLevel],
+ );
// Ungroup a grouped scrubber back into individual scrubbers
const handleUngroupScrubber = useCallback((groupedScrubberId: string) => {
@@ -1547,7 +1417,7 @@ export const useTimeline = () => {
let trackIndex = -1;
for (let i = 0; i < prev.tracks.length; i++) {
- const found = prev.tracks[i].scrubbers.find(s => s.id === groupedScrubberId);
+ const found = prev.tracks[i].scrubbers.find((s) => s.id === groupedScrubberId);
if (found) {
groupedScrubber = found;
trackIndex = i;
@@ -1555,15 +1425,19 @@ export const useTimeline = () => {
}
}
- if (!groupedScrubber || groupedScrubber.mediaType !== "groupped_scrubber" || !groupedScrubber.groupped_scrubbers) {
+ if (
+ !groupedScrubber ||
+ groupedScrubber.mediaType !== "groupped_scrubber" ||
+ !groupedScrubber.groupped_scrubbers
+ ) {
return prev;
}
// Calculate the original bounds when the scrubbers were grouped
const groupedIds = groupedScrubber.groupped_scrubbers || [];
- const originalLeftmost = Math.min(...groupedIds.map(s => s.left));
- const originalRightmost = Math.max(...groupedIds.map(s => s.left + s.width));
- const originalTopmost = Math.min(...groupedIds.map(s => s.y || 0));
+ const originalLeftmost = Math.min(...groupedIds.map((s) => s.left));
+ const originalRightmost = Math.max(...groupedIds.map((s) => s.left + s.width));
+ const originalTopmost = Math.min(...groupedIds.map((s) => s.y || 0));
const originalGroupWidth = originalRightmost - originalLeftmost;
// Calculate scaling based on current grouped scrubber vs original bounds
@@ -1606,10 +1480,7 @@ export const useTimeline = () => {
if (index === trackIndex) {
return {
...track,
- scrubbers: [
- ...track.scrubbers.filter(s => s.id !== groupedScrubberId),
- ...individualScrubbers
- ]
+ scrubbers: [...track.scrubbers.filter((s) => s.id !== groupedScrubberId), ...individualScrubbers],
};
}
return track;
@@ -1619,32 +1490,33 @@ export const useTimeline = () => {
}, []);
// Move a grouped scrubber to media bin and remove from timeline
- const handleMoveGroupToMediaBin = useCallback((groupedScrubberId: string, addToMediaBin: (scrubber: ScrubberState, pixelsPerSecond: number) => void) => {
- // Find the grouped scrubber
- const allScrubbers = getAllScrubbers();
- const groupedScrubber = allScrubbers.find(s => s.id === groupedScrubberId);
-
- if (!groupedScrubber || groupedScrubber.mediaType !== "groupped_scrubber") {
- toast.error("Invalid grouped scrubber");
- return;
- }
+ const handleMoveGroupToMediaBin = useCallback(
+ (groupedScrubberId: string, addToMediaBin: (scrubber: ScrubberState, pixelsPerSecond: number) => void) => {
+ // Find the grouped scrubber
+ const allScrubbers = getAllScrubbers();
+ const groupedScrubber = allScrubbers.find((s) => s.id === groupedScrubberId);
- // Add to media bin first with current pixels per second for correct duration calculation
- addToMediaBin(groupedScrubber, getPixelsPerSecond());
+ if (!groupedScrubber || groupedScrubber.mediaType !== "groupped_scrubber") {
+ toast.error("Invalid grouped scrubber");
+ return;
+ }
- // Then remove from timeline (similar to handleDeleteScrubber)
- setTimeline((prev) => ({
- ...prev,
- tracks: prev.tracks.map((track) => ({
- ...track,
- scrubbers: track.scrubbers.filter(
- (scrubber) => scrubber.id !== groupedScrubberId
- ),
- }))
- }));
+ // Add to media bin first with current pixels per second for correct duration calculation
+ addToMediaBin(groupedScrubber, getPixelsPerSecond());
- toast.success("Moved grouped scrubber to media bin");
- }, [getAllScrubbers, getPixelsPerSecond]);
+ // Then remove from timeline (similar to handleDeleteScrubber)
+ setTimeline((prev) => ({
+ ...prev,
+ tracks: prev.tracks.map((track) => ({
+ ...track,
+ scrubbers: track.scrubbers.filter((scrubber) => scrubber.id !== groupedScrubberId),
+ })),
+ }));
+
+ toast.success("Moved grouped scrubber to media bin");
+ },
+ [getAllScrubbers, getPixelsPerSecond],
+ );
return {
timeline,
diff --git a/app/lib/auth.server.ts b/app/lib/auth.server.ts
index e45f303..d9ae35c 100644
--- a/app/lib/auth.server.ts
+++ b/app/lib/auth.server.ts
@@ -2,7 +2,6 @@ import "dotenv/config";
import { betterAuth } from "better-auth";
import { Pool } from "pg";
-
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || "";
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || "";
@@ -22,20 +21,19 @@ try {
console.log("🔧 Initializing Better Auth with:");
console.log("🔧 DATABASE_URL:", process.env.DATABASE_URL ? "SET" : "NOT SET");
console.log("🔧 GOOGLE_CLIENT_ID:", GOOGLE_CLIENT_ID ? "SET" : "NOT SET");
-console.log(
- "🔧 GOOGLE_CLIENT_SECRET:",
- GOOGLE_CLIENT_SECRET ? "SET" : "NOT SET"
-);
+console.log("🔧 GOOGLE_CLIENT_SECRET:", GOOGLE_CLIENT_SECRET ? "SET" : "NOT SET");
console.log("🔧 Note: baseURL will be auto-detected from request headers");
// Build trusted origins from env + sensible defaults
+const prodDomainHost = process.env.PROD_DOMAIN || "trykimu.com";
+const protocol = prodDomainHost.includes("localhost") ? "http" : "https";
const defaultTrustedOrigins = [
// Dev
"http://localhost:5173",
"http://127.0.0.1:5173",
// Prod (can be overridden/extended via env)
- "https://trykimu.com",
- "https://www.trykimu.com",
+ `${protocol}://${prodDomainHost}`,
+ `${protocol}://www.${prodDomainHost}`,
];
const envTrustedOrigins = (process.env.AUTH_TRUSTED_ORIGINS || "")
@@ -43,26 +41,20 @@ const envTrustedOrigins = (process.env.AUTH_TRUSTED_ORIGINS || "")
.map((s) => s.trim())
.filter(Boolean);
-const trustedOrigins = Array.from(
- new Set([...defaultTrustedOrigins, ...envTrustedOrigins])
-);
+const trustedOrigins = Array.from(new Set([...defaultTrustedOrigins, ...envTrustedOrigins]));
export const auth = betterAuth({
basePath: "/api/auth",
// Force baseURL in development so Google gets the correct redirect_uri
- baseURL:
- process.env.AUTH_BASE_URL ||
- (process.env.NODE_ENV === "development"
- ? "http://localhost:5173"
- : undefined),
+ baseURL: process.env.AUTH_BASE_URL || (process.env.NODE_ENV === "development" ? "http://localhost:5173" : undefined),
// Trust proxy headers to detect HTTPS for secure cookies
trustProxy: process.env.NODE_ENV === "production",
// Let Better Auth auto-detect baseURL from the request
database: new Pool({
connectionString,
- ssl: connectionString.includes('supabase.co')
+ ssl: connectionString.includes("supabase.co")
? { rejectUnauthorized: false } // Supabase uses certificates that may not be trusted by Node.js
- : process.env.NODE_ENV === "production"
+ : process.env.NODE_ENV === "production"
? { rejectUnauthorized: true }
: { rejectUnauthorized: false },
}),
@@ -89,9 +81,7 @@ export const auth = betterAuth({
secure: process.env.NODE_ENV === "production",
// In production, pin cookie domain to apex so subdomains (if any) share
// Set via env if provided, else let browser infer from host header
- ...(process.env.AUTH_COOKIE_DOMAIN
- ? { domain: process.env.AUTH_COOKIE_DOMAIN }
- : {}),
+ ...(process.env.AUTH_COOKIE_DOMAIN ? { domain: process.env.AUTH_COOKIE_DOMAIN } : {}),
path: "/",
},
},
diff --git a/app/lib/timeline.store.ts b/app/lib/timeline.store.ts
index 869ee5a..66071c7 100644
--- a/app/lib/timeline.store.ts
+++ b/app/lib/timeline.store.ts
@@ -1,30 +1,29 @@
import fs from "fs";
import path from "path";
import type { MediaBinItem, TimelineState } from "~/components/timeline/types";
+import { safeResolvePath, ensureDirectoryExists } from "~/utils/path-security";
+import { TimelineStateSchema, MediaBinItemSchema } from "~/schemas/timeline";
const TIMELINE_DIR = process.env.TIMELINE_DIR || path.resolve("project_data");
function ensureDir(): void {
- if (!fs.existsSync(TIMELINE_DIR))
- fs.mkdirSync(TIMELINE_DIR, { recursive: true });
+ ensureDirectoryExists(TIMELINE_DIR);
}
function getFilePath(projectId: string): string {
ensureDir();
+
// Validate and sanitize projectId to prevent path traversal
- if (!projectId || typeof projectId !== 'string') {
- throw new Error('Invalid project ID');
- }
- // Remove any path traversal attempts and invalid characters
- const sanitizedId = projectId.replace(/[^a-zA-Z0-9_-]/g, '');
- if (sanitizedId !== projectId || sanitizedId.length === 0) {
- throw new Error('Invalid project ID format');
+ if (!projectId || typeof projectId !== "string") {
+ throw new Error("Invalid project ID");
}
- const filePath = path.resolve(TIMELINE_DIR, `${sanitizedId}.json`);
- // Ensure the resolved path is still within TIMELINE_DIR
- if (!filePath.startsWith(path.resolve(TIMELINE_DIR))) {
- throw new Error('Invalid path');
+
+ // Use the utility function to safely resolve the path
+ const filePath = safeResolvePath(TIMELINE_DIR, `${projectId}.json`);
+ if (!filePath) {
+ throw new Error("Invalid project ID format");
}
+
return filePath;
}
@@ -44,38 +43,40 @@ function defaultTimeline(): TimelineState {
};
}
-export async function loadProjectState(
- projectId: string
-): Promise {
+export async function loadProjectState(projectId: string): Promise {
const file = getFilePath(projectId);
try {
const raw = await fs.promises.readFile(file, "utf8");
const parsed = JSON.parse(raw);
- if (
- parsed &&
- typeof parsed === "object" &&
- ("timeline" in parsed || "textBinItems" in parsed)
- ) {
+ // Validate modern shape { timeline, textBinItems }
+ if (parsed && typeof parsed === "object" && ("timeline" in parsed || "textBinItems" in parsed)) {
+ const safeTimeline = TimelineStateSchema.safeParse((parsed as any).timeline);
+ const safeTextBinItems = Array.isArray((parsed as any).textBinItems)
+ ? (parsed as any).textBinItems
+ .map((i: unknown) => (MediaBinItemSchema.safeParse(i).success ? i : null))
+ .filter(Boolean)
+ : [];
return {
- timeline: parsed.timeline ?? defaultTimeline(),
- textBinItems: Array.isArray(parsed.textBinItems)
- ? parsed.textBinItems
- : [],
+ timeline: (safeTimeline.success ? safeTimeline.data : defaultTimeline()) as unknown as TimelineState,
+ textBinItems: safeTextBinItems as unknown as MediaBinItem[],
};
}
- // legacy file stored just the timeline
- return { timeline: parsed, textBinItems: [] };
+ // Legacy file stored just the timeline
+ const legacy = TimelineStateSchema.safeParse(parsed);
+ return {
+ timeline: (legacy.success ? legacy.data : defaultTimeline()) as unknown as TimelineState,
+ textBinItems: [],
+ };
} catch {
return { timeline: defaultTimeline(), textBinItems: [] };
}
}
-export async function saveProjectState(
- projectId: string,
- state: ProjectStateFile
-): Promise {
+export async function saveProjectState(projectId: string, state: ProjectStateFile): Promise {
const file = getFilePath(projectId);
- await fs.promises.writeFile(file, JSON.stringify(state), "utf8");
+ const timeline = TimelineStateSchema.parse(state.timeline);
+ const textBinItems = state.textBinItems.map((i) => MediaBinItemSchema.parse(i));
+ await fs.promises.writeFile(file, JSON.stringify({ timeline, textBinItems }), "utf8");
}
// Backwards-compatible helpers
@@ -84,10 +85,7 @@ export async function loadTimeline(projectId: string): Promise {
return state.timeline;
}
-export async function saveTimeline(
- projectId: string,
- timeline: TimelineState
-): Promise {
+export async function saveTimeline(projectId: string, timeline: TimelineState): Promise {
const prev = await loadProjectState(projectId);
await saveProjectState(projectId, {
timeline,
diff --git a/app/routes/api.assets.$.tsx b/app/routes/api.assets.$.tsx
index 39ba3e4..6f75d76 100644
--- a/app/routes/api.assets.$.tsx
+++ b/app/routes/api.assets.$.tsx
@@ -1,10 +1,7 @@
import { auth } from "~/lib/auth.server";
-import {
- insertAsset,
- listAssetsByUser,
- getAssetById,
- softDeleteAsset,
-} from "~/lib/assets.repo";
+import { z } from "zod";
+import { AssetsResponseSchema, RegisterAssetBodySchema, CloneAssetBodySchema } from "~/schemas";
+import { insertAsset, listAssetsByUser, getAssetById, softDeleteAsset } from "~/lib/assets.repo";
import fs from "fs";
import path from "path";
@@ -15,21 +12,15 @@ async function requireUserId(request: Request): Promise {
try {
// @ts-ignore - runtime API may not be typed
const session = await auth.api?.getSession?.({ headers: request.headers });
- const userId: string | undefined =
- session?.user?.id ?? session?.session?.userId;
+ const userId: string | undefined = session?.user?.id ?? session?.session?.userId;
if (userId) return String(userId);
} catch {
console.error("Failed to get session");
}
// Fallback: call /api/auth/session with forwarded cookies
- const host =
- request.headers.get("x-forwarded-host") ||
- request.headers.get("host") ||
- "localhost:5173";
- const proto =
- request.headers.get("x-forwarded-proto") ||
- (host.includes("localhost") ? "http" : "https");
+ const host = request.headers.get("x-forwarded-host") || request.headers.get("host") || "localhost:5173";
+ const proto = request.headers.get("x-forwarded-proto") || (host.includes("localhost") ? "http" : "https");
const base = `${proto}://${host}`;
const cookie = request.headers.get("cookie") || "";
const res = await fetch(`${base}/api/auth/session`, {
@@ -62,15 +53,11 @@ async function requireUserId(request: Request): Promise {
return String(uid);
}
-function inferMediaTypeFromName(
- name: string,
- fallback: string = "application/octet-stream"
-): string {
+function inferMediaTypeFromName(name: string, fallback: string = "application/octet-stream"): string {
const ext = path.extname(name).toLowerCase();
if ([".mp4", ".mov", ".webm", ".mkv", ".avi"].includes(ext)) return "video/*";
if ([".mp3", ".wav", ".aac", ".ogg", ".flac"].includes(ext)) return "audio/*";
- if ([".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"].includes(ext))
- return "image/*";
+ if ([".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"].includes(ext)) return "image/*";
return fallback;
}
@@ -96,11 +83,12 @@ export async function loader({ request }: { request: Request }) {
durationInSeconds: r.duration_seconds, // camelCase for frontend
created_at: r.created_at,
mediaUrlRemote: `/api/assets/${r.id}/raw`,
- fullUrl: `http://localhost:8000/media/${encodeURIComponent(
- r.storage_key
- )}`,
+ // Remove public fullUrl - all access must go through authenticated API
}));
- return new Response(JSON.stringify({ assets: items }), {
+ // Response validation schema
+ const payload = { assets: items };
+ const validated = AssetsResponseSchema.parse(payload);
+ return new Response(JSON.stringify(validated), {
status: 200,
headers: { "Content-Type": "application/json" },
});
@@ -130,19 +118,12 @@ export async function loader({ request }: { request: Request }) {
// Support range requests for video/audio
const stat = fs.statSync(filePath);
const range = request.headers.get("range");
- const contentType =
- asset.mime_type || inferMediaTypeFromName(asset.original_name);
+ const contentType = asset.mime_type || inferMediaTypeFromName(asset.original_name);
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
- if (
- isNaN(start) ||
- isNaN(end) ||
- start > end ||
- start < 0 ||
- end >= stat.size
- ) {
+ if (isNaN(start) || isNaN(end) || start > end || start < 0 || end >= stat.size) {
return new Response(undefined, { status: 416 });
}
const chunkSize = end - start + 1;
@@ -182,8 +163,7 @@ export async function action({ request }: { request: Request }) {
if (pathname.endsWith("/api/assets/upload") && method === "POST") {
const width = Number(request.headers.get("x-media-width") || "") || null;
const height = Number(request.headers.get("x-media-height") || "") || null;
- const duration =
- Number(request.headers.get("x-media-duration") || "") || null;
+ const duration = Number(request.headers.get("x-media-duration") || "") || null;
const originalNameHeader = request.headers.get("x-original-name") || "file";
const projectIdHeader = request.headers.get("x-project-id");
@@ -199,14 +179,13 @@ export async function action({ request }: { request: Request }) {
// Reconstruct a new FormData and forward to 8000 so boundary is correct; faster and streams
const form = new FormData();
- const filenameFor8000 = (media as {name?: string})?.name || originalNameHeader || "upload.bin";
+ const filenameFor8000 = (media as { name?: string })?.name || originalNameHeader || "upload.bin";
form.append("media", media, filenameFor8000);
- // Use HTTPS in production, HTTP only for local development
- const uploadUrl = process.env.NODE_ENV === "production"
- ? process.env.UPLOAD_SERVICE_URL || "https://localhost:8000/upload"
- : "http://localhost:8000/upload";
-
+ // Use internal Docker network URL for backend communication
+ const uploadUrl =
+ process.env.NODE_ENV === "production" ? "http://backend:8000/upload" : "http://localhost:8000/upload";
+
const forwardRes = await fetch(uploadUrl, {
method: "POST",
body: form,
@@ -214,22 +193,16 @@ export async function action({ request }: { request: Request }) {
if (!forwardRes.ok) {
const errText = await forwardRes.text().catch(() => "");
- return new Response(
- JSON.stringify({ error: "Upload failed", detail: errText }),
- {
- status: 500,
- headers: { "Content-Type": "application/json" },
- }
- );
+ return new Response(JSON.stringify({ error: "Upload failed", detail: errText }), {
+ status: 500,
+ headers: { "Content-Type": "application/json" },
+ });
}
-
+
const json = await forwardRes.json();
const filename: string = json.filename;
const size: number = json.size;
- const mime = inferMediaTypeFromName(
- filenameFor8000,
- "application/octet-stream"
- );
+ const mime = inferMediaTypeFromName(filenameFor8000, "application/octet-stream");
const record = await insertAsset({
userId,
@@ -250,40 +223,33 @@ export async function action({ request }: { request: Request }) {
id: record.id,
name: record.original_name,
mediaUrlRemote: `/api/assets/${record.id}/raw`,
- fullUrl: `http://localhost:8000/media/${encodeURIComponent(
- filename
- )}`,
width: record.width,
height: record.height,
durationInSeconds: record.duration_seconds,
size: record.size_bytes,
},
}),
- { status: 200, headers: { "Content-Type": "application/json" } }
+ { status: 200, headers: { "Content-Type": "application/json" } },
);
}
// POST /api/assets/register -> register an already-uploaded file from out/
if (pathname.endsWith("/api/assets/register") && method === "POST") {
const body = await request.json().catch(() => ({}));
- const filename: string | undefined = body.filename;
- const originalName: string | undefined = body.originalName;
- const size: number | undefined = body.size;
- const width: number | null =
- typeof body.width === "number" ? body.width : null;
- const height: number | null =
- typeof body.height === "number" ? body.height : null;
- const duration: number | null =
- typeof body.duration === "number" ? body.duration : null;
+ const parsed = RegisterAssetBodySchema.safeParse(body);
+ if (!parsed.success) {
+ return new Response(JSON.stringify({ error: "Invalid payload" }), {
+ status: 400,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ const { filename, originalName, size, width, height, duration } = parsed.data;
if (!filename || !originalName) {
- return new Response(
- JSON.stringify({ error: "filename and originalName are required" }),
- {
- status: 400,
- headers: { "Content-Type": "application/json" },
- }
- );
+ return new Response(JSON.stringify({ error: "filename and originalName are required" }), {
+ status: 400,
+ headers: { "Content-Type": "application/json" },
+ });
}
const filePath = path.resolve(OUT_DIR, decodeURIComponent(filename));
if (!filePath.startsWith(OUT_DIR) || !fs.existsSync(filePath)) {
@@ -293,10 +259,7 @@ export async function action({ request }: { request: Request }) {
});
}
const stat = fs.statSync(filePath);
- const mime = inferMediaTypeFromName(
- originalName,
- "application/octet-stream"
- );
+ const mime = inferMediaTypeFromName(originalName, "application/octet-stream");
const record = await insertAsset({
userId,
@@ -316,16 +279,13 @@ export async function action({ request }: { request: Request }) {
id: record.id,
name: record.original_name,
mediaUrlRemote: `/api/assets/${record.id}/raw`,
- fullUrl: `http://localhost:8000/media/${encodeURIComponent(
- record.storage_key
- )}`,
width: record.width,
height: record.height,
durationInSeconds: record.duration_seconds,
size: record.size_bytes,
},
}),
- { status: 200, headers: { "Content-Type": "application/json" } }
+ { status: 200, headers: { "Content-Type": "application/json" } },
);
}
@@ -361,7 +321,7 @@ export async function action({ request }: { request: Request }) {
const cloneMatch = pathname.match(/\/api\/assets\/([^/]+)\/clone$/);
if (cloneMatch && method === "POST") {
const assetId = cloneMatch[1];
- const suffix = (await request.json().catch(() => ({})))?.suffix || "copy";
+ const suffix = CloneAssetBodySchema.parse(await request.json().catch(() => ({}))).suffix;
const asset = await getAssetById(assetId);
if (!asset || asset.user_id !== userId) {
return new Response(JSON.stringify({ error: "Not found" }), {
@@ -382,7 +342,7 @@ export async function action({ request }: { request: Request }) {
const ext = path.extname(sanitizedKey);
const base = path.basename(sanitizedKey, ext);
// Sanitize suffix to prevent path traversal in filename
- const sanitizedSuffix = suffix.replace(/[^a-zA-Z0-9_-]/g, '');
+ const sanitizedSuffix = suffix.replace(/[^a-zA-Z0-9_-]/g, "");
const newFilename = `${base}_${sanitizedSuffix}_${timestamp}${ext}`;
const destPath = path.resolve(OUT_DIR, newFilename);
fs.copyFileSync(srcPath, destPath);
@@ -407,16 +367,13 @@ export async function action({ request }: { request: Request }) {
id: record.id,
name: record.original_name,
mediaUrlRemote: `/api/assets/${record.id}/raw`,
- fullUrl: `http://localhost:8000/media/${encodeURIComponent(
- newFilename
- )}`,
width: record.width,
height: record.height,
durationInSeconds: record.duration_seconds,
size: record.size_bytes,
},
}),
- { status: 200, headers: { "Content-Type": "application/json" } }
+ { status: 200, headers: { "Content-Type": "application/json" } },
);
}
diff --git a/app/routes/api.projects.$.tsx b/app/routes/api.projects.$.tsx
index 86709ff..fbbdfca 100644
--- a/app/routes/api.projects.$.tsx
+++ b/app/routes/api.projects.$.tsx
@@ -1,52 +1,35 @@
import { auth } from "~/lib/auth.server";
-import {
- createProject,
- getProjectById,
- listProjectsByUser,
- deleteProjectById,
-} from "~/lib/projects.repo";
-import {
- listAssetsByUser,
- getAssetById,
- softDeleteAsset,
-} from "~/lib/assets.repo";
+import { createProject, getProjectById, listProjectsByUser, deleteProjectById } from "~/lib/projects.repo";
+import { listAssetsByUser, getAssetById, softDeleteAsset } from "~/lib/assets.repo";
import fs from "fs";
import path from "path";
-import {
- loadTimeline,
- saveTimeline,
- loadProjectState,
- saveProjectState,
-} from "~/lib/timeline.store";
+import { loadTimeline, saveTimeline, loadProjectState, saveProjectState } from "~/lib/timeline.store";
import type { MediaBinItem, TimelineState } from "~/components/timeline/types";
+import { z } from "zod";
+import {
+ ProjectsResponseSchema,
+ ProjectStateResponseSchema,
+ CreateProjectBodySchema,
+ PatchProjectBodySchema,
+} from "~/schemas";
async function requireUserId(request: Request): Promise {
try {
const session = await auth.api?.getSession?.({ headers: request.headers });
- const uid: string | undefined =
- session?.user?.id || session?.session?.userId;
+ const uid: string | undefined = session?.user?.id || session?.session?.userId;
if (uid) return String(uid);
} catch {
console.error("Failed to get session");
}
- const host =
- request.headers.get("x-forwarded-host") ||
- request.headers.get("host") ||
- "localhost:5173";
- const proto =
- request.headers.get("x-forwarded-proto") ||
- (host.includes("localhost") ? "http" : "https");
+ const host = request.headers.get("x-forwarded-host") || request.headers.get("host") || "localhost:5173";
+ const proto = request.headers.get("x-forwarded-proto") || (host.includes("localhost") ? "http" : "https");
const base = `${proto}://${host}`;
const res = await fetch(`${base}/api/auth/session`, {
headers: { Cookie: request.headers.get("cookie") || "" },
});
if (!res.ok) throw new Response("Unauthorized", { status: 401 });
const json = await res.json().catch(() => ({}));
- const uid2: string | undefined =
- json?.user?.id ||
- json?.userId ||
- json?.session?.userId ||
- json?.data?.user?.id;
+ const uid2: string | undefined = json?.user?.id || json?.userId || json?.session?.userId || json?.data?.user?.id;
if (!uid2) throw new Response("Unauthorized", { status: 401 });
return String(uid2);
}
@@ -59,7 +42,8 @@ export async function loader({ request }: { request: Request }) {
// GET /api/projects -> list
if (pathname.endsWith("/api/projects") && request.method === "GET") {
const rows = await listProjectsByUser(userId);
- return new Response(JSON.stringify({ projects: rows }), {
+ const payload = ProjectsResponseSchema.parse({ projects: rows });
+ return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
@@ -70,25 +54,21 @@ export async function loader({ request }: { request: Request }) {
if (m && request.method === "GET") {
const id = m[1];
const proj = await getProjectById(id);
- if (!proj || proj.user_id !== userId)
- return new Response("Not Found", { status: 404 });
+ if (!proj || proj.user_id !== userId) return new Response("Not Found", { status: 404 });
const state = await loadProjectState(id);
- return new Response(
- JSON.stringify({
- project: proj,
- timeline: state.timeline,
- textBinItems: state.textBinItems,
- }),
- { status: 200, headers: { "Content-Type": "application/json" } }
- );
+ const payload = ProjectStateResponseSchema.parse({
+ project: proj,
+ timeline: state.timeline,
+ textBinItems: state.textBinItems,
+ });
+ return new Response(JSON.stringify(payload), { status: 200, headers: { "Content-Type": "application/json" } });
}
// DELETE /api/projects/:id -> delete project and assets
if (m && request.method === "DELETE") {
const id = m[1];
const proj = await getProjectById(id);
- if (!proj || proj.user_id !== userId)
- return new Response("Not Found", { status: 404 });
+ if (!proj || proj.user_id !== userId) return new Response("Not Found", { status: 404 });
// Delete assets belonging to this project
try {
@@ -97,17 +77,14 @@ export async function loader({ request }: { request: Request }) {
// Remove file from out/
try {
// Validate storage_key to prevent path traversal
- if (!a.storage_key || typeof a.storage_key !== 'string') {
+ if (!a.storage_key || typeof a.storage_key !== "string") {
console.error("Invalid storage key");
continue;
}
// Sanitize the storage key to prevent path traversal
const sanitizedKey = path.basename(a.storage_key);
const filePath = path.resolve("out", sanitizedKey);
- if (
- filePath.startsWith(path.resolve("out")) &&
- fs.existsSync(filePath)
- ) {
+ if (filePath.startsWith(path.resolve("out")) && fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch {
@@ -123,9 +100,7 @@ export async function loader({ request }: { request: Request }) {
if (!ok) return new Response("Not Found", { status: 404 });
// remove timeline file if exists
try {
- await fs.promises.unlink(
- path.resolve(process.env.TIMELINE_DIR || "project_data", `${id}.json`)
- );
+ await fs.promises.unlink(path.resolve(process.env.TIMELINE_DIR || "project_data", `${id}.json`));
} catch {
console.error("Failed to delete timeline file");
}
@@ -146,7 +121,8 @@ export async function action({ request }: { request: Request }) {
// POST /api/projects -> create
if (pathname.endsWith("/api/projects") && request.method === "POST") {
const body = await request.json().catch(() => ({}));
- const name: string = String(body.name || "Untitled Project").slice(0, 120);
+ const parsed = CreateProjectBodySchema.safeParse(body);
+ const name: string = parsed.success ? parsed.data.name : "Untitled Project";
const proj = await createProject({ userId, name });
return new Response(JSON.stringify({ project: proj }), {
status: 201,
@@ -159,18 +135,14 @@ export async function action({ request }: { request: Request }) {
if (delMatch && request.method === "DELETE") {
const id = delMatch[1];
const proj = await getProjectById(id);
- if (!proj || proj.user_id !== userId)
- return new Response("Not Found", { status: 404 });
+ if (!proj || proj.user_id !== userId) return new Response("Not Found", { status: 404 });
// cascade delete assets (files + soft delete rows)
try {
const assets = await listAssetsByUser(userId, id);
for (const a of assets) {
try {
const filePath = path.resolve("out", a.storage_key);
- if (
- filePath.startsWith(path.resolve("out")) &&
- fs.existsSync(filePath)
- ) {
+ if (filePath.startsWith(path.resolve("out")) && fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch {
@@ -194,18 +166,12 @@ export async function action({ request }: { request: Request }) {
if (patchMatch && request.method === "PATCH") {
const id = patchMatch[1];
const proj = await getProjectById(id);
- if (!proj || proj.user_id !== userId)
- return new Response("Not Found", { status: 404 });
+ if (!proj || proj.user_id !== userId) return new Response("Not Found", { status: 404 });
const body = await request.json().catch(() => ({}));
- const name: string | undefined = body?.name
- ? String(body.name).slice(0, 120)
- : undefined;
- const timeline: TimelineState | undefined = body?.timeline;
- const textBinItems: MediaBinItem[] | undefined = Array.isArray(
- body?.textBinItems
- )
- ? body.textBinItems
- : undefined;
+ const parsed = PatchProjectBodySchema.safeParse(body);
+ const name: string | undefined = parsed.success ? parsed.data.name : undefined;
+ const timeline: TimelineState | undefined = (parsed.success ? parsed.data.timeline : undefined) as any;
+ const textBinItems: MediaBinItem[] | undefined = (parsed.success ? parsed.data.textBinItems : undefined) as any;
if (!name && !timeline && !textBinItems)
return new Response(JSON.stringify({ error: "No changes" }), {
status: 400,
@@ -228,16 +194,15 @@ export async function action({ request }: { request: Request }) {
}
const pool = new Pool({
connectionString,
- ssl: process.env.NODE_ENV === "production"
- ? { rejectUnauthorized: true }
- : { rejectUnauthorized: false }, // Only disable in development
+ ssl: process.env.NODE_ENV === "production" ? { rejectUnauthorized: true } : { rejectUnauthorized: false }, // Only disable in development
});
try {
if (name) {
- await pool.query(
- `update projects set name = $1, updated_at = now() where id = $2 and user_id = $3`,
- [name, id, userId]
- );
+ await pool.query(`update projects set name = $1, updated_at = now() where id = $2 and user_id = $3`, [
+ name,
+ id,
+ userId,
+ ]);
}
} finally {
await pool.end();
diff --git a/app/routes/home.tsx b/app/routes/home.tsx
index fa2c737..035eb64 100644
--- a/app/routes/home.tsx
+++ b/app/routes/home.tsx
@@ -1,4 +1,5 @@
import React, { useRef, useEffect, useCallback, useState } from "react";
+import type { ImperativePanelHandle } from "react-resizable-panels";
import type { PlayerRef, CallbackListener } from "@remotion/player";
import {
Play,
@@ -14,6 +15,10 @@ import {
LogOut,
Save as SaveIcon,
ChevronRight,
+ ChevronLeft,
+ File,
+ Type,
+ BetweenVerticalEnd,
CornerUpLeft,
CornerUpRight,
} from "lucide-react";
@@ -52,7 +57,7 @@ import {
type TrackState,
type ScrubberState,
} from "~/components/timeline/types";
-import { useNavigate, useParams } from "react-router";
+import { useNavigate, useParams, useLocation } from "react-router";
import { ChatBox } from "~/components/chat/ChatBox";
import { KimuLogo } from "~/components/ui/KimuLogo";
import { useAuth } from "~/hooks/useAuth";
@@ -69,11 +74,15 @@ export default function TimelineEditor() {
const containerRef = useRef(null);
const playerRef = useRef(null);
const fileInputRef = useRef(null);
+ const leftPanelRef = useRef(null);
const navigate = useNavigate();
+ const location = useLocation();
const params = useParams();
const projectId = params?.id as string | undefined;
const [projectName, setProjectName] = useState("");
+ const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
+ const [isUserExpandingSidebar, setIsUserExpandingSidebar] = useState(false);
const [width, setWidth] = useState(1920);
const [height, setHeight] = useState(1080);
@@ -154,6 +163,10 @@ export default function TimelineEditor() {
handleCloseContextMenu,
} = useMediaBin(handleDeleteScrubbersByMediaBinId);
+ // Persist MediaBin view state across panel switches
+ const [mediaArrangeMode, setMediaArrangeMode] = useState<"default" | "group">("default");
+ const [mediaSortBy, setMediaSortBy] = useState<"default" | "name_asc" | "name_desc">("default");
+
const {
rulerPositionPx,
isDraggingRuler,
@@ -194,6 +207,49 @@ export default function TimelineEditor() {
fileInputRef.current?.click();
}, []);
+ const openSection = useCallback(
+ (section: "media-bin" | "text-editor" | "transitions") => {
+ const isProjectRoot = /^\/project\/[^/]+\/?$/.test(location.pathname);
+ const isActive =
+ (section === "media-bin" && (location.pathname.includes("/media-bin") || isProjectRoot)) ||
+ (section !== "media-bin" && location.pathname.includes(`/${section}`));
+
+ if (isActive) {
+ if (isSidebarCollapsed) {
+ leftPanelRef.current?.expand?.();
+ setIsSidebarCollapsed(false);
+ setIsUserExpandingSidebar(true);
+ setTimeout(() => leftPanelRef.current?.resize?.(20), 0);
+ } else {
+ leftPanelRef.current?.collapse?.();
+ setIsSidebarCollapsed(true);
+ }
+ return;
+ }
+
+ if (isSidebarCollapsed) {
+ leftPanelRef.current?.expand?.();
+ setIsSidebarCollapsed(false);
+ setIsUserExpandingSidebar(true);
+ setTimeout(() => leftPanelRef.current?.resize?.(20), 0);
+ }
+ navigate(section);
+ },
+ [isSidebarCollapsed, navigate, location.pathname],
+ );
+
+ const toggleSidebar = useCallback(() => {
+ if (isSidebarCollapsed) {
+ leftPanelRef.current?.expand?.();
+ setIsSidebarCollapsed(false);
+ setIsUserExpandingSidebar(true);
+ setTimeout(() => leftPanelRef.current?.resize?.(20), 0);
+ } else {
+ leftPanelRef.current?.collapse?.();
+ setIsSidebarCollapsed(true);
+ }
+ }, [isSidebarCollapsed]);
+
// Hydrate project name and timeline from API
useEffect(() => {
(async () => {
@@ -411,7 +467,13 @@ export default function TimelineEditor() {
return;
}
- handleRenderVideo(getTimelineData, timeline, isAutoSize ? null : width, isAutoSize ? null : height, getPixelsPerSecond);
+ handleRenderVideo(
+ getTimelineData,
+ timeline,
+ isAutoSize ? null : width,
+ isAutoSize ? null : height,
+ getPixelsPerSecond,
+ );
toast.info("Starting render...");
}, [handleRenderVideo, getTimelineData, timeline, width, height, isAutoSize, timelineData, getPixelsPerSecond]);
@@ -466,10 +528,10 @@ export default function TimelineEditor() {
}
if (ctrlKey) {
- setSelectedScrubberIds(prev => {
+ setSelectedScrubberIds((prev) => {
if (prev.includes(scrubberId)) {
// If already selected, remove it
- return prev.filter(id => id !== scrubberId);
+ return prev.filter((id) => id !== scrubberId);
} else {
// If not selected, add it
return [...prev, scrubberId];
@@ -492,8 +554,7 @@ export default function TimelineEditor() {
return;
}
- if (timelineData.length === 0 ||
- timelineData.every((item) => item.scrubbers.length === 0)) {
+ if (timelineData.length === 0 || timelineData.every((item) => item.scrubbers.length === 0)) {
toast.error("No scrubbers to split. Add some media first!");
return;
}
@@ -520,17 +581,23 @@ export default function TimelineEditor() {
}, [selectedScrubberIds, handleGroupScrubbers]);
// Handler for ungrouping a grouped scrubber
- const handleUngroupSelected = useCallback((scrubberId: string) => {
- handleUngroupScrubber(scrubberId);
- setSelectedScrubberIds([]); // Clear selection after ungrouping
- toast.success("Ungrouped scrubber");
- }, [handleUngroupScrubber]);
+ const handleUngroupSelected = useCallback(
+ (scrubberId: string) => {
+ handleUngroupScrubber(scrubberId);
+ setSelectedScrubberIds([]); // Clear selection after ungrouping
+ toast.success("Ungrouped scrubber");
+ },
+ [handleUngroupScrubber],
+ );
// Handler for moving grouped scrubber to media bin
- const handleMoveToMediaBinSelected = useCallback((scrubberId: string) => {
- handleMoveGroupToMediaBin(scrubberId, handleAddGroupToMediaBin);
- setSelectedScrubberIds([]); // Clear selection after moving
- }, [handleMoveGroupToMediaBin, handleAddGroupToMediaBin]);
+ const handleMoveToMediaBinSelected = useCallback(
+ (scrubberId: string) => {
+ handleMoveGroupToMediaBin(scrubberId, handleAddGroupToMediaBin);
+ setSelectedScrubberIds([]); // Clear selection after moving
+ },
+ [handleMoveGroupToMediaBin, handleAddGroupToMediaBin],
+ );
const expandTimelineCallback = useCallback(() => {
return expandTimeline(containerRef);
@@ -742,292 +809,363 @@ export default function TimelineEditor() {
{/* Main content: Left panel full height, center preview+timeline, right chat always visible */}
-
- {/* Left Panel - Media Bin & Tools (full height) */}
-
-
-
+
+ {/* VSCode-like Activity Bar */}
+
+
+ openSection("media-bin")}
+ title="Media Bin">
+
+
+ openSection("text-editor")}
+ title="Text Editor">
+
+
+ openSection("transitions")}
+ title="Transitions">
+
+
-
-
-
-
- {/* Center Area: Preview and Timeline */}
-
-
- {/* Preview Area */}
-
-
- {/* Compact Top Bar */}
-
-
-
Resolution:
-
-
{
- setWidthInput(e.target.value);
- const n = Number(e.target.value);
- if (isFinite(n) && n > 0) setWidth(n);
- }}
- onBlur={commitWidth}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- commitWidth();
- (e.currentTarget as HTMLInputElement).blur();
- }
- }}
- disabled={isAutoSize}
- className="h-5 w-14 text-xs px-1 border-0 bg-muted/50"
- ref={widthInputRef}
- />
-
×
-
{
- setHeightInput(e.target.value);
- const n = Number(e.target.value);
- if (isFinite(n) && n > 0) setHeight(n);
- }}
- onBlur={commitHeight}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- commitHeight();
- (e.currentTarget as HTMLInputElement).blur();
- }
- }}
- disabled={isAutoSize}
- className="h-5 w-14 text-xs px-1 border-0 bg-muted/50"
- ref={heightInputRef}
- />
+
+
+ {isSidebarCollapsed ? : }
+
+
+
+
+
{
+ const leftSize = sizes?.[0] ?? 0;
+ if (isSidebarCollapsed && leftSize > 0 && !isUserExpandingSidebar) {
+ leftPanelRef.current?.collapse?.();
+ setIsSidebarCollapsed(true);
+ return;
+ }
+ if (isUserExpandingSidebar && leftSize >= 12) {
+ setIsUserExpandingSidebar(false);
+ }
+ setIsSidebarCollapsed(leftSize < 1);
+ }}>
+ {/* Left Panel - Media Bin & Tools (full height) */}
+
+
+
+
+
+
+ {/* Hide handle when collapsed to 0 */}
+
+
+ {/* Center Area: Preview and Timeline */}
+
+
+ {/* Preview Area */}
+
+
+ {/* Compact Top Bar */}
+
-
-
-
- Auto
-
+
+
+
+ Auto
+
+
+
+ {!isChatMinimized && null}
+ {isChatMinimized && (
+ <>
+
+
setIsChatMinimized(false)}
+ className="h-6 w-6 p-0 text-primary"
+ title="Open Chat">
+
+
+ >
+ )}
-
- {!isChatMinimized && null}
- {isChatMinimized && (
- <>
-
-
setIsChatMinimized(false)}
- className="h-6 w-6 p-0 text-primary"
- title="Open Chat">
-
-
- >
- )}
-
-
-
- {/* Video Preview */}
-
-
-
- {/* Custom Video Controls - Below Player */}
-
- {/* Left side controls */}
-
-
+ {/* Video Preview */}
+
+
+
- {/* Center play/pause button */}
-
+ {/* Custom Video Controls - Below Player */}
+
+ {/* Left side controls */}
+
+
+
+
+ {/* Center play/pause button */}
+
- {/* Right side controls */}
-
-
+ {/* Right side controls */}
+
+
+
-
-
-
-
-
- {/* Timeline Area */}
-
-
-
-
- Timeline
-
- {Math.round(((durationInFrames || 0) / FPS) * 10) / 10}s
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ {/* Timeline Area */}
+
+
+
+
+
Timeline
+
+ {Math.round(((durationInFrames || 0) / FPS) * 10) / 10}s
+
-
+ onClick={undo}
+ disabled={!canUndo}
+ className="h-6 w-6 p-0"
+ title="Undo (Ctrl/Cmd+Z)">
+
-
- {Math.round(zoomLevel * 100)}%
-
-
+ onClick={redo}
+ disabled={!canRedo}
+ className="h-6 w-6 p-0"
+ title="Redo (Ctrl/Cmd+Shift+Z)">
+
+
+
+
+
+
+
+
+
+ {Math.round(zoomLevel * 100)}%
+
+
+
+
+
+
+
+
+ Track
+
+
+
+
+ Split
+
+
+
+
+ Debug
-
-
-
- Track
-
-
-
-
- Split
-
-
-
-
- Debug
-
-
-
-
-
-
-
-
-
-
- {/* Right Panel - Chat (toggleable) */}
- {!isChatMinimized && (
- <>
-
-
-
- setIsChatMinimized(true)}
- messages={chatMessages}
- onMessagesChange={setChatMessages}
- timelineState={timeline}
- handleUpdateScrubber={handleUpdateScrubberWithLocking}
- handleDeleteScrubber={handleDeleteScrubber}
- />
-
-
- >
- )}
-
+
+
+
+
+
+
+
+
+ {!isChatMinimized && (
+ <>
+
+
+
+ setIsChatMinimized(true)}
+ messages={chatMessages}
+ onMessagesChange={setChatMessages}
+ timelineState={timeline}
+ handleUpdateScrubber={handleUpdateScrubberWithLocking}
+ handleDeleteScrubber={handleDeleteScrubber}
+ pixelsPerSecond={getPixelsPerSecond()}
+ restoreTimeline={setTimelineFromServer}
+ />
+
+
+ >
+ )}
+
+
{/* Hidden file input */}
-
- Marketplace
-
+
Marketplace
Coming soon...
diff --git a/app/routes/privacy.tsx b/app/routes/privacy.tsx
index 1b2a8cf..47973ce 100644
--- a/app/routes/privacy.tsx
+++ b/app/routes/privacy.tsx
@@ -18,6 +18,8 @@ import { KimuLogo } from "~/components/ui/KimuLogo";
import { GlowingEffect } from "~/components/ui/glowing-effect";
export default function Privacy() {
+ const lastUpdated = "30th August 2025";
+
return (
{/* Hero / Masthead */}
@@ -30,16 +32,13 @@ export default function Privacy() {
Privacy & Data Transparency
-
- Your data. Your rules.
-
+
Your data. Your rules.
- Crystal-clear privacy with open-source transparency and explicit
- data boundaries.
+ Crystal-clear privacy with open-source transparency and explicit data boundaries.
- Updated 30th August 2025
+ Updated {lastUpdated}
Version 2.0
@@ -50,31 +49,19 @@ export default function Privacy() {
{/* Document Container */}
-
+
{/* Document Header */}
-
- Kimu Privacy Policy
-
+ Kimu Privacy Policy
-
- How we handle your data
-
+
How we handle your data
- Kimu ("we," "our," or "us") is committed to protecting your
- privacy. This Privacy Policy explains how we collect, use,
- disclose, and safeguard your information when you use our video
- editing application and related services.
+ Kimu ("we," "our," or "us") is committed to protecting your privacy. This Privacy Policy explains how we
+ collect, use, disclose, and safeguard your information when you use our video editing application and
+ related services.
@@ -83,40 +70,29 @@ export default function Privacy() {
{/* 1. Applicability */}
-
- 1. Applicability & Consent
-
+ 1. Applicability & Consent
- This Privacy Policy applies to our online services and is
- valid for visitors and users of our website and web editor
- with regards to information that they share with and/or
- collect in Kimu. This policy does not apply to information
- collected offline or via channels other than this website
- and app.
+ This Privacy Policy applies to our online services and is valid for visitors and users of our
+ website and web editor with regards to information that they share with and/or collect in Kimu. This
+ policy does not apply to information collected offline or via channels other than this website and
+ app.
- By using our website or editor, you hereby consent to this
- Privacy Policy and agree to its terms. If you have
- additional questions or require more information, contact
- us.
+ By using our website or editor, you hereby consent to this Privacy Policy and agree to its terms. If
+ you have additional questions or require more information, contact us.
{/* 2. Information Collection */}
-
- 2. Information We Collect
-
+ 2. Information We Collect
-
- 2.1 Personal Information
-
+
2.1 Personal Information
- We collect the minimum required, and it will be clear at
- the point of collection:
+ We collect the minimum required, and it will be clear at the point of collection:
Email address (for account access)
@@ -126,38 +102,23 @@ export default function Privacy() {
-
- 2.2 Video Content
-
+
2.2 Video Content
- Projects can be local or{" "}
- cloud :
+ Projects can be local or cloud :
-
- Local projects keep media on your device (IndexedDB /
- disk).
-
-
- Cloud projects store media securely on our Hetzner VPS
- for collaboration and access.
-
-
- Project metadata (names, timelines, settings) is stored
- in Supabase.
-
+ Local projects keep media on your device (IndexedDB / disk).
+ Cloud projects store media securely on our Hetzner VPS for collaboration and access.
+ Project metadata (names, timelines, settings) is stored in Supabase.
-
- 2.3 Additional Details You May Provide
-
+
2.3 Additional Details You May Provide
- If you contact us directly, we may receive your name,
- email, phone number, and message contents. During account
- registration or billing, we may request optional details
- like company name or address.
+ If you contact us directly, we may receive your name, email, phone number, and message contents.
+ During account registration or billing, we may request optional details like company name or
+ address.
@@ -165,43 +126,31 @@ export default function Privacy() {
{/* 3. Data Processing */}
-
- 3. How We Process Your Data
-
+ 3. How We Process Your Data
-
- 3.1 Local Processing
-
+
3.1 Local Processing
- Editing and preview are real-time in your browser. For
- local projects, media never leaves your device.
+ Editing and preview are real-time in your browser. For local projects, media never leaves your
+ device.
-
- 3.2 Account Management
-
+
3.2 Account Management
- We use your email address solely for account
- authentication, password recovery, and important service
- notifications.
+ We use your email address solely for account authentication, password recovery, and important
+ service notifications.
-
- 3.3 Cloud Collaboration
-
+
3.3 Cloud Collaboration
- Cloud projects are required for multiplayer. Assets are
- stored securely and only accessible to members granted
- access.
+ Cloud projects are required for multiplayer. Assets are stored securely and only accessible to
+ members granted access.
-
- 3.4 How We Use Information
-
+
3.4 How We Use Information
Provide, operate, and maintain the service
Improve and expand features
@@ -216,24 +165,17 @@ export default function Privacy() {
{/* 4. Data Storage */}
-
- 4. Data Storage and Security
-
+ 4. Data Storage and Security
-
- 4.1 Local Storage
-
+
4.1 Local Storage
- Project files, video assets, and editing history are
- stored locally in your browser's IndexedDB. This data
- remains under your control.
+ Project files, video assets, and editing history are stored locally in your browser's IndexedDB.
+ This data remains under your control.
-
- 4.2 Security Measures
-
+
4.2 Security Measures
All server communications use HTTPS
Account data stored with industry best practices
@@ -241,12 +183,10 @@ export default function Privacy() {
-
- 4.3 Enforcement & Safety
-
+
4.3 Enforcement & Safety
- Uploads are private and secure. Assets violating Terms of
- Service may be removed and accounts suspended.
+ Uploads are private and secure. Assets violating Terms of Service may be removed and accounts
+ suspended.
@@ -254,113 +194,66 @@ export default function Privacy() {
{/* 5. Third-Party Services */}
-
- 5. Third-Party Services
-
+ 5. Third-Party Services
-
+
Hetzner VPS
-
- Secure hosting for services and media storage.
-
+
Secure hosting for services and media storage.
-
+
-
- Google OAuth
-
+ Google OAuth
- Sign-in only. We receive your email and profile if you
- consent.
+ Sign-in only. We receive your email and profile if you consent.
-
+
Supabase
-
- Stores account preferences and project metadata.
-
+
Stores account preferences and project metadata.
-
+
-
- Umami Analytics
-
+ Umami Analytics
-
- Cookie-less, privacy-friendly usage analytics.
-
+
Cookie-less, privacy-friendly usage analytics.
{/* 6. Your Rights */}
-
- 6. Your Privacy Rights
-
+ 6. Your Privacy Rights
You have complete control over your data. You can:
-
- Delete your account
-
- : remove your account and all associated data anytime.
+ Delete your account : remove your account and all
+ associated data anytime.
-
- Export your data
-
- : download your projects in a portable format.
+ Export your data : download your projects in a
+ portable format.
-
- Clear local storage
-
- : remove all locally saved projects.
+ Clear local storage : remove all locally saved
+ projects.
-
- Talk to a human
-
- : contact us anytime with privacy concerns.
+ Talk to a human : contact us anytime with privacy
+ concerns.
@@ -368,13 +261,11 @@ export default function Privacy() {
{/* Open Source */}
-
- 7. Open-Source Transparency
-
+ 7. Open-Source Transparency
- Kimu is open-source. Inspect how data flows, audit changes,
- or contribute. We practice transparent engineering:
+ Kimu is open-source. Inspect how data flows, audit changes, or contribute. We practice transparent
+ engineering:
Public repo, issues, and pull requests
@@ -385,8 +276,7 @@ export default function Privacy() {
href="https://github.com/trykimu/videoeditor"
target="_blank"
rel="noreferrer"
- className="inline-flex items-center gap-2 text-sm px-4 py-2 rounded-md border border-border/40 hover:bg-muted/10"
- >
+ className="inline-flex items-center gap-2 text-sm px-4 py-2 rounded-md border border-border/40 hover:bg-muted/10">
View source on GitHub
@@ -398,22 +288,19 @@ export default function Privacy() {
8. Contact
- Have questions or requests? Create a ticket in our Discord
- or email us.
+ Have questions or requests? Create a ticket in our Discord or email us.
@@ -422,18 +309,14 @@ export default function Privacy() {
{/* Updates */}
-
- 9. Privacy Policy Changes
-
+ 9. Privacy Policy Changes
- We may update this Privacy Policy from time to time. When we
- do, we will publish an updated version and effective date at
- the top of this page, unless another type of notice is
- legally required. Your continued use of Kimu after any
- change in this Privacy Policy will constitute your
- acceptance of such change.
+ We may update this Privacy Policy from time to time. When we do, we will publish an updated version
+ and effective date at the top of this page, unless another type of notice is legally required. Your
+ continued use of Kimu after any change in this Privacy Policy will constitute your acceptance of
+ such change.
@@ -443,16 +326,10 @@ export default function Privacy() {
{/* Document Footer */}
-
- Last updated
- {" "}
- 30th August 2025
+ Last updated {" "}
+ {lastUpdated}
-
+
Return to Kimu
diff --git a/app/routes/project.$id.tsx b/app/routes/project.$id.tsx
index b781879..02a31a4 100644
--- a/app/routes/project.$id.tsx
+++ b/app/routes/project.$id.tsx
@@ -4,6 +4,7 @@ import TimelineEditor from "./home";
import { auth } from "~/lib/auth.server";
import { loadTimeline } from "~/lib/timeline.store";
import type { TimelineState } from "~/components/timeline/types";
+import { IdParamSchema } from "~/schemas";
export async function loader({ request, params }: LoaderFunctionArgs) {
// SSR gate: verify auth
@@ -18,8 +19,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
} catch {
return new Response(null, { status: 302, headers: { Location: "/login" } });
}
+ // Validate route param
+ const id = IdParamSchema.parse(params.id);
// Optionally prefetch timeline to hydrate client faster
- const id = params.id as string;
const timeline = await loadTimeline(id);
return { timeline };
}
diff --git a/app/schemas/apis/assets.ts b/app/schemas/apis/assets.ts
new file mode 100644
index 0000000..560b4b0
--- /dev/null
+++ b/app/schemas/apis/assets.ts
@@ -0,0 +1,35 @@
+import { z } from "zod";
+
+const numberish = z.union([z.number(), z.string()]).transform((v) => (typeof v === "number" ? v : parseFloat(v)));
+const dateLikeToString = (v: unknown) => (v instanceof Date ? v.toISOString() : String(v));
+const DateString = z.union([z.string(), z.date()]).transform((v) => dateLikeToString(v));
+
+export const AssetsResponseSchema = z.object({
+ assets: z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ mime_type: z.string(),
+ size_bytes: numberish,
+ width: z.number().nullable(),
+ height: z.number().nullable(),
+ duration_seconds: z.number().nullable(),
+ durationInSeconds: z.number().nullable(),
+ created_at: DateString,
+ mediaUrlRemote: z.string(),
+ }),
+ ),
+});
+
+const opt =
(schema: T) =>
+ schema.nullish().transform((v) => v ?? (undefined as z.infer | undefined));
+export const RegisterAssetBodySchema = z.object({
+ filename: z.string(),
+ originalName: z.string(),
+ size: opt(z.number()),
+ width: z.number().nullish(),
+ height: z.number().nullish(),
+ duration: z.number().nullish(),
+});
+
+export const CloneAssetBodySchema = z.object({ suffix: z.string().default("copy") });
diff --git a/app/schemas/apis/params.ts b/app/schemas/apis/params.ts
new file mode 100644
index 0000000..87af125
--- /dev/null
+++ b/app/schemas/apis/params.ts
@@ -0,0 +1,4 @@
+import { z } from "zod";
+
+export const IdParamSchema = z.string().min(1).max(128);
+
diff --git a/app/schemas/apis/projects.ts b/app/schemas/apis/projects.ts
new file mode 100644
index 0000000..efe0f68
--- /dev/null
+++ b/app/schemas/apis/projects.ts
@@ -0,0 +1,44 @@
+import { z } from "zod";
+
+const dateLikeToString = (v: unknown) => (v instanceof Date ? v.toISOString() : String(v));
+const DateString = z.union([z.string(), z.date()]).transform((v) => dateLikeToString(v));
+
+export const ProjectsResponseSchema = z.object({
+ projects: z.array(
+ z.object({
+ id: z.string(),
+ user_id: z.string(),
+ name: z.string(),
+ created_at: DateString,
+ updated_at: DateString,
+ }),
+ ),
+});
+
+export const ProjectStateResponseSchema = z.object({
+ project: z.object({
+ id: z.string(),
+ user_id: z.string(),
+ name: z.string(),
+ created_at: DateString,
+ updated_at: DateString,
+ }),
+ timeline: z.unknown(),
+ textBinItems: z.array(z.unknown()),
+});
+
+export const CreateProjectBodySchema = z.object({ name: z.string().min(1).max(120).default("Untitled Project") });
+
+const opt = (schema: T) =>
+ schema.nullish().transform((v) => v ?? (undefined as z.infer | undefined));
+export const PatchProjectBodySchema = z.object({
+ name: opt(z.string().min(1).max(120)),
+ timeline: z
+ .unknown()
+ .nullish()
+ .transform((v) => v ?? undefined),
+ textBinItems: z
+ .array(z.unknown())
+ .nullish()
+ .transform((v) => v ?? undefined),
+});
diff --git a/app/schemas/auth.ts b/app/schemas/auth.ts
new file mode 100644
index 0000000..68161a3
--- /dev/null
+++ b/app/schemas/auth.ts
@@ -0,0 +1,45 @@
+import { z } from "zod";
+
+export const AuthUserSchema = z.object({
+ id: z.string(),
+ email: z.string().email().nullable().optional(),
+ name: z.string().nullable().optional(),
+ image: z.string().url().nullable().optional(),
+});
+
+// Various possible Better Auth response envelopes
+export const BetterAuthUserSchema = z.object({
+ id: z.union([z.string(), z.number()]).optional(),
+ userId: z.union([z.string(), z.number()]).optional(),
+ email: z.string().optional(),
+ name: z.string().optional(),
+ image: z.string().optional(),
+ avatarUrl: z.string().optional(),
+});
+
+export const BetterAuthEnvelopeSchema = z.object({
+ user: BetterAuthUserSchema.optional(),
+ data: z.object({ user: BetterAuthUserSchema.optional() }).optional(),
+ session: z
+ .object({
+ user: BetterAuthUserSchema.optional(),
+ userId: z.union([z.string(), z.number()]).optional(),
+ })
+ .optional(),
+});
+
+export function normalizeAuthUser(input: unknown): z.infer | null {
+ const env = BetterAuthEnvelopeSchema.safeParse(input);
+ if (!env.success) return null;
+ const raw = env.data.user || env.data.data?.user || env.data.session?.user;
+ const id = raw?.id ?? raw?.userId ?? env.data.session?.userId;
+ if (!id) return null;
+ const normalized = {
+ id: String(id),
+ email: (raw?.email as string | undefined) ?? null,
+ name: (raw?.name as string | undefined) ?? null,
+ image: (raw?.image as string | undefined) ?? (raw?.avatarUrl as string | undefined) ?? null,
+ };
+ return AuthUserSchema.parse(normalized);
+}
+
diff --git a/app/schemas/components/chat.ts b/app/schemas/components/chat.ts
new file mode 100644
index 0000000..c3187c6
--- /dev/null
+++ b/app/schemas/components/chat.ts
@@ -0,0 +1,30 @@
+export {
+ AiResponseSchema,
+ MoveScrubberArgsSchema,
+ ResizeScrubberArgsSchema,
+ AddScrubberByNameArgsSchema,
+ AddMediaByIdArgsSchema,
+ DeleteScrubbersInTrackArgsSchema,
+ UpdateTextContentArgsSchema,
+ UpdateTextStyleArgsSchema,
+ MoveScrubbersByOffsetArgsSchema,
+} from "../llm";
+
+import { z } from "zod";
+
+export const ChatMessageSchema = z.object({
+ id: z.string(),
+ content: z.string(),
+ isUser: z.boolean(),
+ timestamp: z.union([z.date(), z.string()]).transform((v) => (typeof v === "string" ? new Date(v) : v)),
+});
+
+export const ChatTabSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ messages: z.array(ChatMessageSchema),
+ timelineSnapshot: z.unknown().nullable(),
+ createdAt: z.number(),
+});
+
+export const ChatTabsStorageSchema = z.array(ChatTabSchema);
diff --git a/app/schemas/components/media.ts b/app/schemas/components/media.ts
new file mode 100644
index 0000000..4712f09
--- /dev/null
+++ b/app/schemas/components/media.ts
@@ -0,0 +1,12 @@
+import { z } from "zod";
+
+export const TransitionDragPayloadSchema = z.object({
+ id: z.string(),
+ type: z.literal("transition"),
+ presentation: z.enum(["fade", "wipe", "clockWipe", "slide", "flip", "iris"]),
+ timing: z.enum(["linear", "spring"]).default("linear"),
+ durationInFrames: z.number().int().positive(),
+ leftScrubberId: z.string().nullable(),
+ rightScrubberId: z.string().nullable(),
+});
+
diff --git a/app/schemas/components/timeline.ts b/app/schemas/components/timeline.ts
new file mode 100644
index 0000000..fa11eb4
--- /dev/null
+++ b/app/schemas/components/timeline.ts
@@ -0,0 +1,9 @@
+export {
+ MediaBinItemSchema,
+ ScrubberStateSchema,
+ TrackStateSchema,
+ TimelineStateSchema,
+ TextPropertiesSchema,
+ TransitionSchema,
+} from "../timeline";
+
diff --git a/app/schemas/index.ts b/app/schemas/index.ts
new file mode 100644
index 0000000..b2a9131
--- /dev/null
+++ b/app/schemas/index.ts
@@ -0,0 +1,9 @@
+export * from "./timeline";
+export * from "./llm";
+export * from "./auth";
+export * from "./components/chat";
+export * from "./components/timeline";
+export * from "./components/media";
+export * from "./apis/assets";
+export * from "./apis/projects";
+export * from "./apis/params";
diff --git a/app/schemas/llm.ts b/app/schemas/llm.ts
new file mode 100644
index 0000000..4687795
--- /dev/null
+++ b/app/schemas/llm.ts
@@ -0,0 +1,106 @@
+import { z } from "zod";
+
+// Coercion helpers
+export const numberish = z.union([z.number(), z.string()]).transform((v) => {
+ if (typeof v === "number") return v;
+ const n = parseFloat(v as string);
+ if (!Number.isFinite(n)) throw new Error("Invalid number");
+ return n;
+});
+
+export const seconds = numberish.refine((n) => Number.isFinite(n) && n >= 0, "Invalid seconds");
+
+// Nullish handling helpers (guideline: prefer nullish -> undefined transforms)
+const opt = (schema: T) =>
+ schema.nullish().transform((v) => (v ?? undefined) as z.infer | undefined);
+export const optNumberish = opt(numberish);
+export const optSeconds = opt(seconds);
+export const optString = opt(z.string());
+
+export const FunctionCallSchema = z.object({
+ function_name: z.string(),
+ arguments: z.record(z.unknown()).default({}),
+});
+
+export const AiResponseSchema = z.object({
+ function_call: FunctionCallSchema.optional(),
+ assistant_message: z.string().optional(),
+});
+
+// Per-function argument schemas (extend over time)
+export const MoveScrubberArgsSchema = z.object({
+ scrubber_id: z.string(),
+ new_position_seconds: optSeconds,
+ position_seconds: optSeconds,
+ start_seconds: optSeconds,
+ new_track_number: optNumberish,
+ track_number: optNumberish,
+ pixels_per_second: optNumberish,
+});
+
+export const ResizeScrubberArgsSchema = z.object({
+ scrubber_id: optString,
+ new_duration_seconds: optSeconds,
+ duration_seconds: optSeconds,
+ seconds: optSeconds,
+ duration: optSeconds,
+ newDurationSeconds: optSeconds,
+ durationInSeconds: optSeconds,
+ start_seconds: optSeconds,
+ position_seconds: optSeconds,
+ end_seconds: optSeconds,
+ track_number: optNumberish,
+ new_track_number: optNumberish,
+ pixels_per_second: optNumberish,
+ scrubber_name: optString,
+ new_text_content: optString,
+});
+
+export const AddScrubberByNameArgsSchema = z.object({
+ scrubber_name: z.string(),
+ pixels_per_second: optNumberish,
+ start_seconds: optSeconds,
+ position_seconds: optSeconds,
+ track_number: optNumberish,
+ end_seconds: optSeconds,
+ duration_seconds: optSeconds,
+});
+
+export const AddMediaByIdArgsSchema = z.object({
+ scrubber_id: z.string(),
+ pixels_per_second: optNumberish,
+ start_seconds: optSeconds,
+ track_number: optNumberish,
+ end_seconds: optSeconds,
+ duration_seconds: optSeconds,
+});
+
+export const DeleteScrubbersInTrackArgsSchema = z.object({
+ track_number: optNumberish,
+});
+
+export const UpdateTextContentArgsSchema = z.object({
+ scrubber_id: z.string(),
+ new_text_content: z.string(),
+});
+
+export const UpdateTextStyleArgsSchema = z.object({
+ scrubber_id: z.string(),
+ fontSize: optNumberish,
+ fontFamily: optString,
+ color: optString,
+ textAlign: z
+ .enum(["left", "center", "right"])
+ .nullish()
+ .transform((v) => v ?? undefined),
+ fontWeight: z
+ .enum(["normal", "bold"])
+ .nullish()
+ .transform((v) => v ?? undefined),
+});
+
+export const MoveScrubbersByOffsetArgsSchema = z.object({
+ scrubber_ids: z.array(z.string()),
+ offset_seconds: seconds,
+ pixels_per_second: optNumberish,
+});
diff --git a/app/schemas/timeline.ts b/app/schemas/timeline.ts
new file mode 100644
index 0000000..61a0580
--- /dev/null
+++ b/app/schemas/timeline.ts
@@ -0,0 +1,68 @@
+import { z } from "zod";
+
+export const TextPropertiesSchema = z.object({
+ textContent: z.string(),
+ fontSize: z.number(),
+ fontFamily: z.string(),
+ color: z.string(),
+ textAlign: z.enum(["left", "center", "right"]),
+ fontWeight: z.enum(["normal", "bold"]),
+ template: z.enum(["normal", "glassy"]).nullable(),
+});
+
+export const TransitionSchema = z.object({
+ id: z.string(),
+ presentation: z.enum(["fade", "wipe", "clockWipe", "slide", "flip", "iris"]),
+ timing: z.enum(["spring", "linear"]),
+ durationInFrames: z.number().int().nonnegative(),
+ leftScrubberId: z.string().nullable(),
+ rightScrubberId: z.string().nullable(),
+});
+
+export const MediaBinBaseSchema = z.object({
+ id: z.string(),
+ mediaType: z.enum(["video", "image", "audio", "text", "groupped_scrubber"]),
+ mediaUrlLocal: z.string().nullable(),
+ mediaUrlRemote: z.string().nullable(),
+ media_width: z.number(),
+ media_height: z.number(),
+ text: TextPropertiesSchema.nullable(),
+ groupped_scrubbers: z.any().nullable(),
+ left_transition_id: z.string().nullable(),
+ right_transition_id: z.string().nullable(),
+});
+
+export const MediaBinItemSchema = MediaBinBaseSchema.extend({
+ name: z.string(),
+ durationInSeconds: z.number().nonnegative(),
+ uploadProgress: z.number().nullable(),
+ isUploading: z.boolean(),
+});
+
+export const ScrubberStateSchema = MediaBinItemSchema.extend({
+ left: z.number().nonnegative(),
+ y: z.number().int().nonnegative(),
+ width: z.number().nonnegative(),
+ sourceMediaBinId: z.string(),
+ left_player: z.number(),
+ top_player: z.number(),
+ width_player: z.number(),
+ height_player: z.number(),
+ is_dragging: z.boolean(),
+ trimBefore: z.number().int().nullable(),
+ trimAfter: z.number().int().nullable(),
+});
+
+export const TrackStateSchema = z.object({
+ id: z.string(),
+ scrubbers: z.array(ScrubberStateSchema),
+ transitions: z.array(TransitionSchema),
+});
+
+export const TimelineStateSchema = z.object({
+ tracks: z.array(TrackStateSchema),
+});
+
+export type TimelineStateParsed = z.infer;
+export type ScrubberStateParsed = z.infer;
+
diff --git a/app/utils/api.ts b/app/utils/api.ts
index 1f3dfb5..a1cb79e 100644
--- a/app/utils/api.ts
+++ b/app/utils/api.ts
@@ -1,12 +1,28 @@
+const safeEnv = (key: string, fallback?: string): string | undefined => {
+ try {
+ // In browser environments process may be undefined
+ // @ts-ignore
+ return typeof process !== "undefined" && process.env ? process.env[key] : fallback;
+ } catch {
+ return fallback;
+ }
+};
+
export const getApiBaseUrl = (fastapi: boolean = false, betterauth: boolean = false): string => {
- const isProduction = process.env.NODE_ENV === "production";
+ const nodeEnv = safeEnv("NODE_ENV", "development");
+ const isProduction = nodeEnv === "production";
+ const prodDomainHost = safeEnv("PROD_DOMAIN", "trykimu.com") as string;
+
+ // Handle localhost development case
+ const protocol = prodDomainHost.includes("localhost") ? "http" : "https";
+ const prodDomain = `${protocol}://${prodDomainHost}`;
if (betterauth) {
- return isProduction ? "https://trykimu.com" : "http://localhost:5173"; // frontend NOTE: this will be deleted, it is repeating logic. It'll be the default.
+ return isProduction ? prodDomain : "http://localhost:5173"; // frontend NOTE: this will be deleted, it is repeating logic. It'll be the default.
} else if (fastapi) {
- return isProduction ? "https://trykimu.com/ai/api" : "http://127.0.0.1:3000"; // fastapi backend
+ return isProduction ? `${prodDomain}/ai/api` : "http://127.0.0.1:3000"; // fastapi backend
} else {
- return isProduction ? "https://trykimu.com/render" : "http://localhost:8000"; // remotion render server
+ return isProduction ? `${prodDomain}/render` : "http://localhost:8000"; // remotion render server
}
};
@@ -15,4 +31,4 @@ export const apiUrl = (endpoint: string, fastapi: boolean = false, betterauth: b
const path = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
return path ? `${baseUrl}${path}` : `${baseUrl}`;
-};
\ No newline at end of file
+};
diff --git a/app/utils/llm-handler.ts b/app/utils/llm-handler.ts
index 23cf283..bbe61b5 100644
--- a/app/utils/llm-handler.ts
+++ b/app/utils/llm-handler.ts
@@ -1,6 +1,13 @@
// because there is only a fixed set of tools the LLM can use in a video editor, we're going to be writing functions for those tools and then calling them from the LLM.
-import { type MediaBinItem, type ScrubberState, type TimelineState, type TrackState, type TimelineDataItem, FPS } from "~/components/timeline/types";
+import {
+ type MediaBinItem,
+ type ScrubberState,
+ type TimelineState,
+ type TrackState,
+ type TimelineDataItem,
+ FPS,
+} from "~/components/timeline/types";
import { generateUUID } from "./uuid";
// ============================
@@ -8,21 +15,20 @@ import { generateUUID } from "./uuid";
// ============================
export function llmAddScrubberToTimeline(
- id: string,
- mediaBinItems: MediaBinItem[],
- track: string,
- dropLeftPx: number,
- handleDropOnTrack: (item: MediaBinItem, trackId: string, dropLeftPx: number) => void
+ id: string,
+ mediaBinItems: MediaBinItem[],
+ track: string,
+ dropLeftPx: number,
+ handleDropOnTrack: (item: MediaBinItem, trackId: string, dropLeftPx: number) => void,
) {
// take a scrubber from the media bin and add it to the timeline. It is best to leave the import to media bin to the user.
- const scrubber = mediaBinItems.find(item => item.id === id);
+ const scrubber = mediaBinItems.find((item) => item.id === id);
if (!scrubber) {
throw new Error(`Scrubber with id ${id} not found`);
}
handleDropOnTrack(scrubber, track, dropLeftPx);
}
-
// everything below is untested and written by claude
export function llmAddScrubberByName(
@@ -31,11 +37,9 @@ export function llmAddScrubberByName(
trackNumber: number,
positionSeconds: number,
pixelsPerSecond: number,
- handleDropOnTrack: (item: MediaBinItem, trackId: string, dropLeftPx: number) => void
+ handleDropOnTrack: (item: MediaBinItem, trackId: string, dropLeftPx: number) => void,
) {
- const scrubber = mediaBinItems.find(item =>
- item.name.toLowerCase().includes(name.toLowerCase())
- );
+ const scrubber = mediaBinItems.find((item) => item.name.toLowerCase().includes(name.toLowerCase()));
if (!scrubber) {
throw new Error(`Media item with name "${name}" not found`);
}
@@ -44,26 +48,63 @@ export function llmAddScrubberByName(
handleDropOnTrack(scrubber, trackId, dropLeftPx);
}
+export function llmAddMediaByIdWithDuration(
+ scrubberId: string,
+ mediaBinItems: MediaBinItem[],
+ trackNumber: number,
+ startSeconds: number,
+ pixelsPerSecond: number,
+ handleDropOnTrack: (item: MediaBinItem, trackId: string, dropLeftPx: number) => void,
+ handleUpdateScrubber: (updatedScrubber: ScrubberState) => void,
+ desiredDurationSeconds?: number,
+ endSeconds?: number,
+) {
+ const item = mediaBinItems.find((i) => i.id === scrubberId);
+ if (!item) {
+ throw new Error(`Media item with id "${scrubberId}" not found`);
+ }
+
+ const trackId = `track-${trackNumber}`;
+ const startPx = Math.max(0, Math.round(startSeconds * pixelsPerSecond));
+ handleDropOnTrack(item, trackId, startPx);
+
+ // If a duration or end time is specified, adjust the last added scrubber on that track
+ const actualDuration = (() => {
+ if (typeof desiredDurationSeconds === "number" && desiredDurationSeconds > 0) {
+ return desiredDurationSeconds;
+ }
+ if (typeof endSeconds === "number" && endSeconds > startSeconds) {
+ return endSeconds - startSeconds;
+ }
+ // fallback: intrinsic duration or default for images
+ if (item.mediaType === "image") return 5;
+ return item.durationInSeconds || 5;
+ })();
+
+ // Return a function to finalize width once we know scrubber id from state, but here we assume the consumer will update
+ // In typical flow, caller will locate the scrubber by name + position and adjust width via llmResizeScrubber
+}
+
export function llmMoveScrubber(
scrubberId: string,
newPositionSeconds: number,
newTrackNumber: number,
pixelsPerSecond: number,
timeline: TimelineState,
- handleUpdateScrubber: (updatedScrubber: ScrubberState) => void
+ handleUpdateScrubber: (updatedScrubber: ScrubberState) => void,
) {
- const allScrubbers = timeline.tracks.flatMap(track => track.scrubbers);
- const scrubber = allScrubbers.find(s => s.id === scrubberId);
+ const allScrubbers = timeline.tracks.flatMap((track) => track.scrubbers);
+ const scrubber = allScrubbers.find((s) => s.id === scrubberId);
if (!scrubber) {
throw new Error(`Scrubber with id ${scrubberId} not found`);
}
-
+
const updatedScrubber: ScrubberState = {
...scrubber,
left: newPositionSeconds * pixelsPerSecond,
- y: newTrackNumber - 1 // Convert to 0-based index
+ y: newTrackNumber - 1, // Convert to 0-based index
};
-
+
handleUpdateScrubber(updatedScrubber);
}
@@ -72,34 +113,34 @@ export function llmResizeScrubber(
newDurationSeconds: number,
pixelsPerSecond: number,
timeline: TimelineState,
- handleUpdateScrubber: (updatedScrubber: ScrubberState) => void
+ handleUpdateScrubber: (updatedScrubber: ScrubberState) => void,
) {
- const allScrubbers = timeline.tracks.flatMap(track => track.scrubbers);
- const scrubber = allScrubbers.find(s => s.id === scrubberId);
+ const allScrubbers = timeline.tracks.flatMap((track) => track.scrubbers);
+ const scrubber = allScrubbers.find((s) => s.id === scrubberId);
if (!scrubber) {
throw new Error(`Scrubber with id ${scrubberId} not found`);
}
-
+
const updatedScrubber: ScrubberState = {
...scrubber,
- width: newDurationSeconds * pixelsPerSecond
+ width: newDurationSeconds * pixelsPerSecond,
};
-
+
handleUpdateScrubber(updatedScrubber);
}
export function llmDeleteScrubber(
scrubberId: string,
timeline: TimelineState,
- handleUpdateScrubber: (updatedScrubber: ScrubberState) => void
+ handleUpdateScrubber: (updatedScrubber: ScrubberState) => void,
) {
// Find and remove scrubber by setting its width to 0 (effectively deleting it)
- const allScrubbers = timeline.tracks.flatMap(track => track.scrubbers);
- const scrubber = allScrubbers.find(s => s.id === scrubberId);
+ const allScrubbers = timeline.tracks.flatMap((track) => track.scrubbers);
+ const scrubber = allScrubbers.find((s) => s.id === scrubberId);
if (!scrubber) {
throw new Error(`Scrubber with id ${scrubberId} not found`);
}
-
+
// Remove by setting width to 0 or marking for deletion
// Note: In a real implementation, you'd need a proper delete function
throw new Error("Delete function needs to be implemented in the timeline hook");
@@ -108,7 +149,7 @@ export function llmDeleteScrubber(
export function llmDeleteScrubbersInTrack(
trackNumber: number,
timeline: TimelineState,
- handleDeleteScrubber: (scrubberId: string) => void
+ handleDeleteScrubber: (scrubberId: string) => void,
) {
const trackIndex = trackNumber - 1;
if (trackIndex < 0 || trackIndex >= timeline.tracks.length) {
@@ -125,23 +166,23 @@ export function llmDeleteScrubbersInTrack(
// TRACK OPERATIONS
// ============================
-export function llmAddTrack(
- handleAddTrack: () => void
-) {
+export function llmAddTrack(handleAddTrack: () => void) {
handleAddTrack();
}
-export function llmDeleteTrack(
- trackId: string,
- handleDeleteTrack: (trackId: string) => void
-) {
+export function llmCreateTracks(count: number, handleAddTrack: () => void) {
+ const n = Math.max(1, Math.floor(count));
+ for (let i = 0; i < n; i++) handleAddTrack();
+}
+
+export function llmDeleteTrack(trackId: string, handleDeleteTrack: (trackId: string) => void) {
handleDeleteTrack(trackId);
}
export function llmDeleteTrackByNumber(
trackNumber: number,
timeline: TimelineState,
- handleDeleteTrack: (trackId: string) => void
+ handleDeleteTrack: (trackId: string) => void,
) {
const trackIndex = trackNumber - 1; // Convert to 0-based index
if (trackIndex < 0 || trackIndex >= timeline.tracks.length) {
@@ -155,28 +196,22 @@ export function llmDeleteTrackByNumber(
// TIMELINE ZOOM & NAVIGATION
// ============================
-export function llmZoomIn(
- handleZoomIn: () => void
-) {
+export function llmZoomIn(handleZoomIn: () => void) {
handleZoomIn();
}
-export function llmZoomOut(
- handleZoomOut: () => void
-) {
+export function llmZoomOut(handleZoomOut: () => void) {
handleZoomOut();
}
-export function llmZoomReset(
- handleZoomReset: () => void
-) {
+export function llmZoomReset(handleZoomReset: () => void) {
handleZoomReset();
}
export function llmSetTimelinePosition(
timeSeconds: number,
pixelsPerSecond: number,
- handleRulerDrag: (positionPx: number) => void
+ handleRulerDrag: (positionPx: number) => void,
) {
const positionPx = timeSeconds * pixelsPerSecond;
handleRulerDrag(positionPx);
@@ -186,9 +221,7 @@ export function llmSetTimelinePosition(
// PLAYBACK CONTROLS
// ============================
-export function llmPlay(
- playerRef: React.RefObject<{ play: () => void } | null>
-) {
+export function llmPlay(playerRef: React.RefObject<{ play: () => void } | null>) {
if (playerRef.current) {
playerRef.current.play();
} else {
@@ -196,9 +229,7 @@ export function llmPlay(
}
}
-export function llmPause(
- playerRef: React.RefObject<{ pause: () => void } | null>
-) {
+export function llmPause(playerRef: React.RefObject<{ pause: () => void } | null>) {
if (playerRef.current) {
playerRef.current.pause();
} else {
@@ -206,15 +237,13 @@ export function llmPause(
}
}
-export function llmTogglePlayback(
- togglePlayback: () => void
-) {
+export function llmTogglePlayback(togglePlayback: () => void) {
togglePlayback();
}
export function llmSeekToTime(
timeSeconds: number,
- playerRef: React.RefObject<{ seekTo: (frame: number) => void } | null>
+ playerRef: React.RefObject<{ seekTo: (frame: number) => void } | null>,
) {
if (playerRef.current) {
const frame = Math.round(timeSeconds * FPS);
@@ -224,10 +253,7 @@ export function llmSeekToTime(
}
}
-export function llmSeekToFrame(
- frame: number,
- playerRef: React.RefObject<{ seekTo: (frame: number) => void } | null>
-) {
+export function llmSeekToFrame(frame: number, playerRef: React.RefObject<{ seekTo: (frame: number) => void } | null>) {
if (playerRef.current) {
playerRef.current.seekTo(frame);
} else {
@@ -239,9 +265,7 @@ export function llmSeekToFrame(
// MEDIA BIN OPERATIONS
// ============================
-export function llmAddMediaFile(
- handleAddMediaClick: () => void
-) {
+export function llmAddMediaFile(handleAddMediaClick: () => void) {
// Triggers file picker dialog
handleAddMediaClick();
}
@@ -259,8 +283,8 @@ export function llmAddTextToMediaBin(
fontFamily: string,
color: string,
textAlign: "left" | "center" | "right",
- fontWeight: "normal" | "bold"
- ) => void
+ fontWeight: "normal" | "bold",
+ ) => void,
) {
handleAddTextToBin(textContent, fontSize, fontFamily, color, textAlign, fontWeight);
}
@@ -268,25 +292,18 @@ export function llmAddTextToMediaBin(
export function llmRemoveMediaFromBin(
itemId: string,
mediaBinItems: MediaBinItem[],
- setMediaBinItems: (items: MediaBinItem[]) => void
+ setMediaBinItems: (items: MediaBinItem[]) => void,
) {
- const filteredItems = mediaBinItems.filter(item => item.id !== itemId);
+ const filteredItems = mediaBinItems.filter((item) => item.id !== itemId);
setMediaBinItems(filteredItems);
}
-export function llmGetMediaBinItem(
- itemName: string,
- mediaBinItems: MediaBinItem[]
-): MediaBinItem | null {
- return mediaBinItems.find(item =>
- item.name.toLowerCase().includes(itemName.toLowerCase())
- ) || null;
+export function llmGetMediaBinItem(itemName: string, mediaBinItems: MediaBinItem[]): MediaBinItem | null {
+ return mediaBinItems.find((item) => item.name.toLowerCase().includes(itemName.toLowerCase())) || null;
}
-export function llmListMediaBinItems(
- mediaBinItems: MediaBinItem[]
-): string[] {
- return mediaBinItems.map(item => `${item.name} (${item.mediaType})`);
+export function llmListMediaBinItems(mediaBinItems: MediaBinItem[]): string[] {
+ return mediaBinItems.map((item) => `${item.name} (${item.mediaType})`);
}
// ============================
@@ -297,37 +314,25 @@ export function llmSetResolution(
width: number,
height: number,
handleWidthChange: (width: number) => void,
- handleHeightChange: (height: number) => void
+ handleHeightChange: (height: number) => void,
) {
handleWidthChange(width);
handleHeightChange(height);
}
-export function llmSetWidth(
- width: number,
- handleWidthChange: (width: number) => void
-) {
+export function llmSetWidth(width: number, handleWidthChange: (width: number) => void) {
handleWidthChange(width);
}
-export function llmSetHeight(
- height: number,
- handleHeightChange: (height: number) => void
-) {
+export function llmSetHeight(height: number, handleHeightChange: (height: number) => void) {
handleHeightChange(height);
}
-export function llmToggleAutoSize(
- handleAutoSizeChange: (auto: boolean) => void,
- currentState: boolean
-) {
+export function llmToggleAutoSize(handleAutoSizeChange: (auto: boolean) => void, currentState: boolean) {
handleAutoSizeChange(!currentState);
}
-export function llmSetAutoSize(
- autoSize: boolean,
- handleAutoSizeChange: (auto: boolean) => void
-) {
+export function llmSetAutoSize(autoSize: boolean, handleAutoSizeChange: (auto: boolean) => void) {
handleAutoSizeChange(autoSize);
}
@@ -335,9 +340,7 @@ export function llmSetAutoSize(
// RENDERING OPERATIONS
// ============================
-export function llmRenderVideo(
- handleRenderClick: () => void
-) {
+export function llmRenderVideo(handleRenderClick: () => void) {
handleRenderClick();
}
@@ -350,8 +353,8 @@ export function llmStartRender(
getTimelineData: () => TimelineDataItem[],
timeline: TimelineState,
width: number | null,
- height: number | null
- ) => void
+ height: number | null,
+ ) => void,
) {
handleRenderVideo(getTimelineData, timeline, width, height);
}
@@ -360,30 +363,26 @@ export function llmStartRender(
// DEBUG & LOGGING
// ============================
-export function llmLogTimelineData(
- handleLogTimelineData: () => void
-) {
+export function llmLogTimelineData(handleLogTimelineData: () => void) {
handleLogTimelineData();
}
-export function llmGetTimelineStats(
- timeline: TimelineState
-): {
+export function llmGetTimelineStats(timeline: TimelineState): {
trackCount: number;
totalScrubbers: number;
totalDuration: number;
scrubbersByTrack: { [trackId: string]: number };
} {
- const allScrubbers = timeline.tracks.flatMap(track => track.scrubbers);
+ const allScrubbers = timeline.tracks.flatMap((track) => track.scrubbers);
let maxEndTime = 0;
-
- allScrubbers.forEach(scrubber => {
+
+ allScrubbers.forEach((scrubber) => {
const endTime = (scrubber.left + scrubber.width) / 100; // Assuming 100 pixels per second
if (endTime > maxEndTime) maxEndTime = endTime;
});
const scrubbersByTrack: { [trackId: string]: number } = {};
- timeline.tracks.forEach(track => {
+ timeline.tracks.forEach((track) => {
scrubbersByTrack[track.id] = track.scrubbers.length;
});
@@ -391,7 +390,7 @@ export function llmGetTimelineStats(
trackCount: timeline.tracks.length,
totalScrubbers: allScrubbers.length,
totalDuration: maxEndTime,
- scrubbersByTrack
+ scrubbersByTrack,
};
}
@@ -408,19 +407,19 @@ export function llmUpdateScrubberInPlayer(
height_player?: number;
},
timeline: TimelineState,
- handleUpdateScrubber: (updatedScrubber: ScrubberState) => void
+ handleUpdateScrubber: (updatedScrubber: ScrubberState) => void,
) {
- const allScrubbers = timeline.tracks.flatMap(track => track.scrubbers);
- const scrubber = allScrubbers.find(s => s.id === scrubberId);
+ const allScrubbers = timeline.tracks.flatMap((track) => track.scrubbers);
+ const scrubber = allScrubbers.find((s) => s.id === scrubberId);
if (!scrubber) {
throw new Error(`Scrubber with id ${scrubberId} not found`);
}
-
+
const updatedScrubber: ScrubberState = {
...scrubber,
- ...properties
+ ...properties,
};
-
+
handleUpdateScrubber(updatedScrubber);
}
@@ -429,20 +428,20 @@ export function llmScaleScrubberInPlayer(
scaleX: number,
scaleY: number,
timeline: TimelineState,
- handleUpdateScrubber: (updatedScrubber: ScrubberState) => void
+ handleUpdateScrubber: (updatedScrubber: ScrubberState) => void,
) {
- const allScrubbers = timeline.tracks.flatMap(track => track.scrubbers);
- const scrubber = allScrubbers.find(s => s.id === scrubberId);
+ const allScrubbers = timeline.tracks.flatMap((track) => track.scrubbers);
+ const scrubber = allScrubbers.find((s) => s.id === scrubberId);
if (!scrubber) {
throw new Error(`Scrubber with id ${scrubberId} not found`);
}
-
+
const updatedScrubber: ScrubberState = {
...scrubber,
width_player: scrubber.width_player * scaleX,
- height_player: scrubber.height_player * scaleY
+ height_player: scrubber.height_player * scaleY,
};
-
+
handleUpdateScrubber(updatedScrubber);
}
@@ -451,14 +450,9 @@ export function llmPositionScrubberInPlayer(
x: number,
y: number,
timeline: TimelineState,
- handleUpdateScrubber: (updatedScrubber: ScrubberState) => void
+ handleUpdateScrubber: (updatedScrubber: ScrubberState) => void,
) {
- llmUpdateScrubberInPlayer(
- scrubberId,
- { left_player: x, top_player: y },
- timeline,
- handleUpdateScrubber
- );
+ llmUpdateScrubberInPlayer(scrubberId, { left_player: x, top_player: y }, timeline, handleUpdateScrubber);
}
// ============================
@@ -469,23 +463,23 @@ export function llmUpdateTextContent(
scrubberId: string,
newTextContent: string,
timeline: TimelineState,
- handleUpdateScrubber: (updatedScrubber: ScrubberState) => void
+ handleUpdateScrubber: (updatedScrubber: ScrubberState) => void,
) {
- const allScrubbers = timeline.tracks.flatMap(track => track.scrubbers);
- const scrubber = allScrubbers.find(s => s.id === scrubberId);
+ const allScrubbers = timeline.tracks.flatMap((track) => track.scrubbers);
+ const scrubber = allScrubbers.find((s) => s.id === scrubberId);
if (!scrubber || scrubber.mediaType !== "text" || !scrubber.text) {
throw new Error(`Text scrubber with id ${scrubberId} not found`);
}
-
+
const updatedScrubber: ScrubberState = {
...scrubber,
name: newTextContent,
text: {
...scrubber.text,
- textContent: newTextContent
- }
+ textContent: newTextContent,
+ },
};
-
+
handleUpdateScrubber(updatedScrubber);
}
@@ -499,22 +493,22 @@ export function llmUpdateTextStyle(
fontWeight?: "normal" | "bold";
},
timeline: TimelineState,
- handleUpdateScrubber: (updatedScrubber: ScrubberState) => void
+ handleUpdateScrubber: (updatedScrubber: ScrubberState) => void,
) {
- const allScrubbers = timeline.tracks.flatMap(track => track.scrubbers);
- const scrubber = allScrubbers.find(s => s.id === scrubberId);
+ const allScrubbers = timeline.tracks.flatMap((track) => track.scrubbers);
+ const scrubber = allScrubbers.find((s) => s.id === scrubberId);
if (!scrubber || scrubber.mediaType !== "text" || !scrubber.text) {
throw new Error(`Text scrubber with id ${scrubberId} not found`);
}
-
+
const updatedScrubber: ScrubberState = {
...scrubber,
text: {
...scrubber.text,
- ...styleProperties
- }
+ ...styleProperties,
+ },
};
-
+
handleUpdateScrubber(updatedScrubber);
}
@@ -522,20 +516,15 @@ export function llmUpdateTextStyle(
// BULK OPERATIONS
// ============================
-export function llmSelectAllScrubbers(
- timeline: TimelineState
-): string[] {
- return timeline.tracks.flatMap(track => track.scrubbers).map(s => s.id);
+export function llmSelectAllScrubbers(timeline: TimelineState): string[] {
+ return timeline.tracks.flatMap((track) => track.scrubbers).map((s) => s.id);
}
-export function llmSelectScrubbersByType(
- mediaType: "video" | "image" | "text",
- timeline: TimelineState
-): string[] {
+export function llmSelectScrubbersByType(mediaType: "video" | "image" | "text", timeline: TimelineState): string[] {
return timeline.tracks
- .flatMap(track => track.scrubbers)
- .filter(s => s.mediaType === mediaType)
- .map(s => s.id);
+ .flatMap((track) => track.scrubbers)
+ .filter((s) => s.mediaType === mediaType)
+ .map((s) => s.id);
}
export function llmMoveScrubbersByOffset(
@@ -543,17 +532,17 @@ export function llmMoveScrubbersByOffset(
offsetSeconds: number,
pixelsPerSecond: number,
timeline: TimelineState,
- handleUpdateScrubber: (updatedScrubber: ScrubberState) => void
+ handleUpdateScrubber: (updatedScrubber: ScrubberState) => void,
) {
- const allScrubbers = timeline.tracks.flatMap(track => track.scrubbers);
+ const allScrubbers = timeline.tracks.flatMap((track) => track.scrubbers);
const offsetPx = offsetSeconds * pixelsPerSecond;
-
- scrubberIds.forEach(id => {
- const scrubber = allScrubbers.find(s => s.id === id);
+
+ scrubberIds.forEach((id) => {
+ const scrubber = allScrubbers.find((s) => s.id === id);
if (scrubber) {
const updatedScrubber: ScrubberState = {
...scrubber,
- left: Math.max(0, scrubber.left + offsetPx)
+ left: Math.max(0, scrubber.left + offsetPx),
};
handleUpdateScrubber(updatedScrubber);
}
@@ -564,82 +553,63 @@ export function llmMoveScrubbersByOffset(
// UTILITY FUNCTIONS
// ============================
-export function llmConvertTimeToPixels(
- timeSeconds: number,
- pixelsPerSecond: number
-): number {
+export function llmConvertTimeToPixels(timeSeconds: number, pixelsPerSecond: number): number {
return timeSeconds * pixelsPerSecond;
}
-export function llmConvertPixelsToTime(
- pixels: number,
- pixelsPerSecond: number
-): number {
+export function llmConvertPixelsToTime(pixels: number, pixelsPerSecond: number): number {
return pixels / pixelsPerSecond;
}
-export function llmGetTimelineDuration(
- timeline: TimelineState,
- pixelsPerSecond: number
-): number {
- const allScrubbers = timeline.tracks.flatMap(track => track.scrubbers);
+export function llmGetTimelineDuration(timeline: TimelineState, pixelsPerSecond: number): number {
+ const allScrubbers = timeline.tracks.flatMap((track) => track.scrubbers);
let maxEndPosition = 0;
-
- allScrubbers.forEach(scrubber => {
+
+ allScrubbers.forEach((scrubber) => {
const endPosition = scrubber.left + scrubber.width;
if (endPosition > maxEndPosition) {
maxEndPosition = endPosition;
}
});
-
+
return maxEndPosition / pixelsPerSecond;
}
-export function llmFindScrubberByName(
- name: string,
- timeline: TimelineState
-): ScrubberState | null {
- const allScrubbers = timeline.tracks.flatMap(track => track.scrubbers);
- return allScrubbers.find(s =>
- s.name.toLowerCase().includes(name.toLowerCase())
- ) || null;
+export function llmFindScrubberByName(name: string, timeline: TimelineState): ScrubberState | null {
+ const allScrubbers = timeline.tracks.flatMap((track) => track.scrubbers);
+ return allScrubbers.find((s) => s.name.toLowerCase().includes(name.toLowerCase())) || null;
}
export function llmGetScrubberAtPosition(
timeSeconds: number,
trackNumber: number,
pixelsPerSecond: number,
- timeline: TimelineState
+ timeline: TimelineState,
): ScrubberState | null {
const trackIndex = trackNumber - 1;
if (trackIndex < 0 || trackIndex >= timeline.tracks.length) {
return null;
}
-
+
const positionPx = timeSeconds * pixelsPerSecond;
const track = timeline.tracks[trackIndex];
-
- return track.scrubbers.find(scrubber =>
- positionPx >= scrubber.left && positionPx <= scrubber.left + scrubber.width
- ) || null;
+
+ return (
+ track.scrubbers.find((scrubber) => positionPx >= scrubber.left && positionPx <= scrubber.left + scrubber.width) ||
+ null
+ );
}
// ============================
// THEME & UI OPERATIONS
// ============================
-export function llmToggleTheme(
- currentTheme: string,
- setTheme: (theme: string) => void
-) {
+export function llmToggleTheme(currentTheme: string, setTheme: (theme: string) => void) {
const newTheme = currentTheme === "dark" ? "light" : "dark";
setTheme(newTheme);
}
-export function llmSetTheme(
- theme: "light" | "dark",
- setTheme: (theme: string) => void
-) {
+export function llmSetTheme(theme: "light" | "dark", setTheme: (theme: string) => void) {
setTheme(theme);
}
@@ -647,21 +617,15 @@ export function llmSetTheme(
// NAVIGATION OPERATIONS
// ============================
-export function llmNavigateToTextEditor(
- navigate: (path: string) => void
-) {
+export function llmNavigateToTextEditor(navigate: (path: string) => void) {
navigate("/editor/text-editor");
}
-export function llmNavigateToMediaBin(
- navigate: (path: string) => void
-) {
+export function llmNavigateToMediaBin(navigate: (path: string) => void) {
navigate("/editor/media-bin");
}
-export function llmNavigateHome(
- navigate: (path: string) => void
-) {
+export function llmNavigateHome(navigate: (path: string) => void) {
navigate("/");
}
@@ -669,53 +633,51 @@ export function llmNavigateHome(
// VALIDATION FUNCTIONS
// ============================
-export function llmValidateTimelineForRender(
- timeline: TimelineState
-): { isValid: boolean; errors: string[] } {
+export function llmValidateTimelineForRender(timeline: TimelineState): { isValid: boolean; errors: string[] } {
const errors: string[] = [];
-
+
if (timeline.tracks.length === 0) {
errors.push("No tracks in timeline");
}
-
- const hasAnyContent = timeline.tracks.some(track => track.scrubbers.length > 0);
+
+ const hasAnyContent = timeline.tracks.some((track) => track.scrubbers.length > 0);
if (!hasAnyContent) {
errors.push("No media content in timeline");
}
-
+
return {
isValid: errors.length === 0,
- errors
+ errors,
};
}
-export function llmCheckForCollisions(
- timeline: TimelineState
-): { hasCollisions: boolean; collisions: Array<{ scrubber1: string; scrubber2: string; track: string }> } {
+export function llmCheckForCollisions(timeline: TimelineState): {
+ hasCollisions: boolean;
+ collisions: Array<{ scrubber1: string; scrubber2: string; track: string }>;
+} {
const collisions: Array<{ scrubber1: string; scrubber2: string; track: string }> = [];
-
- timeline.tracks.forEach(track => {
+
+ timeline.tracks.forEach((track) => {
const scrubbers = track.scrubbers;
for (let i = 0; i < scrubbers.length; i++) {
for (let j = i + 1; j < scrubbers.length; j++) {
const s1 = scrubbers[i];
const s2 = scrubbers[j];
-
+
// Check if they overlap
if (!(s1.left + s1.width <= s2.left || s2.left + s2.width <= s1.left)) {
collisions.push({
scrubber1: s1.id,
scrubber2: s2.id,
- track: track.id
+ track: track.id,
});
}
}
}
});
-
+
return {
hasCollisions: collisions.length > 0,
- collisions
+ collisions,
};
}
-
diff --git a/app/utils/path-security.ts b/app/utils/path-security.ts
new file mode 100644
index 0000000..bfdc239
--- /dev/null
+++ b/app/utils/path-security.ts
@@ -0,0 +1,144 @@
+import path from "path";
+import fs from "fs";
+
+/**
+ * Utility functions for secure path operations
+ * Prevents path traversal attacks and ensures files stay within intended directories
+ */
+
+/**
+ * Safely resolves a file path within a specified base directory
+ * @param baseDir - The base directory to confine files to (e.g., "out")
+ * @param filename - The filename to resolve
+ * @returns The resolved absolute path if safe, null if unsafe
+ */
+export function safeResolvePath(baseDir: string, filename: string): string | null {
+ try {
+ // Sanitize filename - remove any path traversal attempts
+ const sanitizedFilename = path.basename(filename);
+
+ // Only allow alphanumeric, hyphens, underscores, dots, and timestamps
+ if (!/^[a-zA-Z0-9._-]+$/.test(sanitizedFilename)) {
+ return null;
+ }
+
+ // Resolve the path and ensure it's within the base directory
+ const resolvedPath = path.resolve(baseDir, sanitizedFilename);
+ const baseDirResolved = path.resolve(baseDir);
+
+ // Security check - ensure resolved path is within base directory
+ if (!resolvedPath.startsWith(baseDirResolved) || resolvedPath === baseDirResolved) {
+ return null;
+ }
+
+ return resolvedPath;
+ } catch (error) {
+ return null;
+ }
+}
+
+/**
+ * Safely resolves a file path within the "out" directory (common use case)
+ * @param filename - The filename to resolve
+ * @returns The resolved absolute path if safe, null if unsafe
+ */
+export function safeResolveOutPath(filename: string): string | null {
+ return safeResolvePath("out", filename);
+}
+
+/**
+ * Validates if a filename is safe (no path traversal, valid characters)
+ * @param filename - The filename to validate
+ * @returns true if safe, false if unsafe
+ */
+export function isValidFilename(filename: string): boolean {
+ try {
+ const sanitizedFilename = path.basename(filename);
+ return /^[a-zA-Z0-9._-]+$/.test(sanitizedFilename);
+ } catch (error) {
+ return false;
+ }
+}
+
+/**
+ * Sanitizes a filename by removing unsafe characters and path traversal attempts
+ * @param filename - The filename to sanitize
+ * @returns The sanitized filename, or null if too unsafe
+ */
+export function sanitizeFilename(filename: string): string | null {
+ try {
+ const sanitized = path.basename(filename);
+
+ // Remove any remaining unsafe characters
+ const cleaned = sanitized.replace(/[^a-zA-Z0-9._-]/g, "");
+
+ // Return null if the filename becomes empty or too short
+ if (!cleaned || cleaned.length < 1) {
+ return null;
+ }
+
+ return cleaned;
+ } catch (error) {
+ return null;
+ }
+}
+
+/**
+ * Creates a safe filename with timestamp and optional suffix
+ * @param originalName - The original filename
+ * @param suffix - Optional suffix to add
+ * @returns A safe filename with timestamp
+ */
+export function createSafeFilename(originalName: string, suffix?: string): string {
+ const timestamp = Date.now();
+ const extension = path.extname(originalName);
+ const nameWithoutExt = path.basename(originalName, extension);
+
+ // Sanitize the base name
+ const sanitizedBase = sanitizeFilename(nameWithoutExt) || "file";
+
+ // Sanitize suffix if provided
+ const sanitizedSuffix = suffix ? sanitizeFilename(suffix) || "" : "";
+
+ // Combine parts
+ const parts = [sanitizedBase];
+ if (sanitizedSuffix) {
+ parts.push(sanitizedSuffix);
+ }
+ parts.push(timestamp.toString());
+
+ return `${parts.join("_")}${extension}`;
+}
+
+/**
+ * Ensures a directory exists, creating it if necessary
+ * @param dirPath - The directory path to ensure
+ * @returns true if successful, false if failed
+ */
+export function ensureDirectoryExists(dirPath: string): boolean {
+ try {
+ if (!fs.existsSync(dirPath)) {
+ fs.mkdirSync(dirPath, { recursive: true });
+ }
+ return true;
+ } catch (error) {
+ return false;
+ }
+}
+
+/**
+ * Checks if a file path is within a specified base directory
+ * @param filePath - The file path to check
+ * @param baseDir - The base directory to check against
+ * @returns true if the file is within the base directory, false otherwise
+ */
+export function isPathWithinDirectory(filePath: string, baseDir: string): boolean {
+ try {
+ const resolvedFilePath = path.resolve(filePath);
+ const resolvedBaseDir = path.resolve(baseDir);
+
+ return resolvedFilePath.startsWith(resolvedBaseDir) && resolvedFilePath !== resolvedBaseDir;
+ } catch (error) {
+ return false;
+ }
+}
diff --git a/app/videorender/videorender.ts b/app/videorender/videorender.ts
index 3d65fc4..e1efdad 100644
--- a/app/videorender/videorender.ts
+++ b/app/videorender/videorender.ts
@@ -1,18 +1,19 @@
-import { bundle } from '@remotion/bundler';
-import { renderMedia, selectComposition } from '@remotion/renderer';
-import path from 'path';
-import express, { type Request, type Response } from 'express';
-import cors from 'cors';
-import fs from 'fs';
-import multer from 'multer';
+import { bundle } from "@remotion/bundler";
+import { renderMedia, selectComposition } from "@remotion/renderer";
+import path from "path";
+import express, { type Request, type Response } from "express";
+import cors from "cors";
+import fs from "fs";
+import multer from "multer";
+import { safeResolveOutPath, createSafeFilename, ensureDirectoryExists, isValidFilename } from "~/utils/path-security";
// The composition you want to render
-const compositionId = 'TimelineComposition';
+const compositionId = "TimelineComposition";
// You only have to create a bundle once, and you may reuse it
// for multiple renders that you can parametrize using input props.
const bundleLocation = await bundle({
- entryPoint: path.resolve('./app/videorender/index.ts'),
+ entryPoint: path.resolve("./app/videorender/index.ts"),
// If you have a webpack override in remotion.config.ts, pass it here as well.
webpackOverride: (config) => config,
});
@@ -20,38 +21,31 @@ const bundleLocation = await bundle({
console.log(bundleLocation);
// Ensure output directory exists
-if (!fs.existsSync('out')) {
- fs.mkdirSync('out', { recursive: true });
-}
+ensureDirectoryExists("out");
const app = express();
app.use(express.json());
app.use(cors());
// Static file serving for the out/ directory
-app.use('/media', express.static(path.resolve('out'), {
- dotfiles: 'deny',
- index: false
-}));
+// REMOVED: Direct media serving - all asset access must go through authenticated API
+// app.use('/media', express.static(path.resolve('out'), {
+// dotfiles: 'deny',
+// index: false
+// }));
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// Ensure out directory exists
- if (!fs.existsSync('out')) {
- fs.mkdirSync('out', { recursive: true });
- }
- cb(null, 'out/');
+ ensureDirectoryExists("out");
+ cb(null, "out/");
},
filename: (req, file, cb) => {
- // Generate unique filename with timestamp
- const timestamp = Date.now();
- const originalName = file.originalname;
- const extension = path.extname(originalName);
- const nameWithoutExt = path.basename(originalName, extension);
- const uniqueName = `${nameWithoutExt}_${timestamp}${extension}`;
+ // Generate unique filename with timestamp using utility function
+ const uniqueName = createSafeFilename(file.originalname);
cb(null, uniqueName);
- }
+ },
});
const upload = multer({
@@ -65,49 +59,54 @@ const upload = multer({
if (allowedTypes.test(file.originalname)) {
cb(null, true);
} else {
- cb(new Error('Invalid file type. Only media files are allowed.'));
+ cb(new Error("Invalid file type. Only media files are allowed."));
}
- }
+ },
});
-// List files in out/ directory
-app.get('/media', (req: Request, res: Response): void => {
+// Internal media serving for Remotion composition only (Docker network access)
+app.get("/media/:filename", (req: Request, res: Response): void => {
try {
- const outDir = path.resolve('out');
- if (!fs.existsSync(outDir)) {
- res.json({ files: [] });
+ const filename = req.params.filename;
+ const decodedFilename = decodeURIComponent(filename);
+
+ // Safely resolve the file path
+ const filePath = safeResolveOutPath(decodedFilename);
+ if (!filePath) {
+ res.status(403).json({ error: "Invalid filename" });
return;
}
- const files = fs.readdirSync(outDir).map(filename => {
- const filePath = path.join(outDir, filename);
- const stats = fs.statSync(filePath);
- return {
- name: filename,
- url: `/media/${encodeURIComponent(filename)}`,
- size: stats.size,
- modified: stats.mtime,
- isDirectory: stats.isDirectory()
- };
- }).filter(file => !file.isDirectory); // Only show files, not directories
+ if (!fs.existsSync(filePath)) {
+ res.status(404).json({ error: "File not found" });
+ return;
+ }
- res.json({ files });
+ // Serve the file for internal use
+ res.sendFile(filePath);
} catch (error) {
- console.error('Error listing files:', error);
- res.status(500).json({ error: 'Failed to list files' });
+ console.error("Error serving media file:", error);
+ res.status(500).json({ error: "Failed to serve file" });
}
});
// File upload endpoint
-app.post('/upload', upload.single('media'), (req: Request, res: Response): void => {
+app.post("/upload", upload.single("media"), (req: Request, res: Response): void => {
try {
if (!req.file) {
- res.status(400).json({ error: 'No file uploaded' });
+ res.status(400).json({ error: "No file uploaded" });
+ return;
+ }
+
+ // Validate the uploaded file path is safe
+ const safePath = safeResolveOutPath(req.file.filename);
+ if (!safePath) {
+ res.status(400).json({ error: "Invalid filename generated" });
return;
}
const fileUrl = `/media/${encodeURIComponent(req.file.filename)}`;
- const fullUrl = `http://localhost:${port}${fileUrl}`; // Direct backend URL for Remotion
+ const fullUrl = `http://localhost:${port}${fileUrl}`; // For internal Remotion composition only
console.log(`📁 File uploaded: ${req.file.originalname} -> ${req.file.filename}`);
@@ -118,143 +117,152 @@ app.post('/upload', upload.single('media'), (req: Request, res: Response): void
url: fileUrl,
fullUrl: fullUrl,
size: req.file.size,
- path: req.file.path
+ path: req.file.path,
});
} catch (error) {
- console.error('Upload error:', error);
- res.status(500).json({ error: 'File upload failed' });
+ console.error("Upload error:", error);
+ res.status(500).json({ error: "File upload failed" });
}
});
// Bulk file upload endpoint
-app.post('/upload-multiple', upload.array('media', 10), (req: Request, res: Response): void => {
+app.post("/upload-multiple", upload.array("media", 10), (req: Request, res: Response): void => {
try {
if (!req.files || req.files.length === 0) {
- res.status(400).json({ error: 'No files uploaded' });
+ res.status(400).json({ error: "No files uploaded" });
return;
}
- const uploadedFiles = (req.files as Express.Multer.File[]).map(file => ({
- filename: file.filename,
- originalName: file.originalname,
- url: `/media/${encodeURIComponent(file.filename)}`,
- fullUrl: `http://localhost:${port}/media/${encodeURIComponent(file.filename)}`, // Direct backend URL for Remotion
- size: file.size,
- path: file.path
- }));
+ const uploadedFiles = (req.files as Express.Multer.File[]).map((file) => {
+ // Validate each uploaded file path is safe
+ const safePath = safeResolveOutPath(file.filename);
+ if (!safePath) {
+ throw new Error(`Invalid filename generated: ${file.filename}`);
+ }
+
+ return {
+ filename: file.filename,
+ originalName: file.originalname,
+ url: `/media/${encodeURIComponent(file.filename)}`,
+ fullUrl: `http://localhost:${port}/media/${encodeURIComponent(file.filename)}`, // For internal Remotion composition only
+ size: file.size,
+ path: file.path,
+ };
+ });
console.log(`📁 ${uploadedFiles.length} files uploaded`);
res.json({
success: true,
- files: uploadedFiles
+ files: uploadedFiles,
});
} catch (error) {
- console.error('Bulk upload error:', error);
- res.status(500).json({ error: 'Bulk file upload failed' });
+ console.error("Bulk upload error:", error);
+ res.status(500).json({ error: "Bulk file upload failed" });
}
});
// Clone/copy media file endpoint
-app.post('/clone-media', (req: Request, res: Response): void => {
+app.post("/clone-media", (req: Request, res: Response): void => {
try {
const { filename, originalName, suffix } = req.body;
-
+
if (!filename) {
- res.status(400).json({ error: 'Filename is required' });
+ res.status(400).json({ error: "Filename is required" });
return;
}
-
- const decodedFilename = decodeURIComponent(filename);
- const sourcePath = path.resolve('out', decodedFilename);
-
- // Security check - ensure source file is in the out directory
- if (!sourcePath.startsWith(path.resolve('out'))) {
- res.status(403).json({ error: 'Access denied' });
+
+ // Safely resolve the source file path
+ const sourcePath = safeResolveOutPath(filename);
+ if (!sourcePath) {
+ res.status(403).json({ error: "Invalid filename" });
return;
}
-
+
if (!fs.existsSync(sourcePath)) {
- res.status(404).json({ error: 'Source file not found' });
+ res.status(404).json({ error: "Source file not found" });
return;
}
-
+
// Generate new filename with timestamp and suffix
- const timestamp = Date.now();
- const sourceExtension = path.extname(decodedFilename);
- const sourceNameWithoutExt = path.basename(decodedFilename, sourceExtension);
- const newFilename = `${sourceNameWithoutExt}_${suffix}_${timestamp}${sourceExtension}`;
- const destPath = path.resolve('out', newFilename);
-
+ const newFilename = createSafeFilename(filename, suffix);
+
+ // Safely resolve the destination path
+ const destPath = safeResolveOutPath(newFilename);
+ if (!destPath) {
+ res.status(400).json({ error: "Invalid destination filename generated" });
+ return;
+ }
+
// Copy the file
fs.copyFileSync(sourcePath, destPath);
-
+
const fileStats = fs.statSync(destPath);
const fileUrl = `/media/${encodeURIComponent(newFilename)}`;
- const fullUrl = `http://localhost:${port}${fileUrl}`;
-
- console.log(`📋 File cloned: ${decodedFilename} -> ${newFilename}`);
-
+ const fullUrl = `http://localhost:${port}${fileUrl}`; // For internal Remotion composition only
+
+ console.log(`📋 File cloned: ${filename} -> ${newFilename}`);
+
res.json({
success: true,
filename: newFilename,
- originalName: originalName || decodedFilename,
+ originalName: originalName || filename,
url: fileUrl,
fullUrl: fullUrl,
size: fileStats.size,
- path: destPath
+ path: destPath,
});
} catch (error) {
- console.error('Clone error:', error);
- res.status(500).json({ error: 'Failed to clone file' });
+ console.error("Clone error:", error);
+ res.status(500).json({ error: "Failed to clone file" });
}
});
// Delete file endpoint
-app.delete('/media/:filename', (req: Request, res: Response): void => {
+app.delete("/media/:filename", (req: Request, res: Response): void => {
try {
- const filename = decodeURIComponent(req.params.filename);
- const filePath = path.resolve('out', filename);
-
- // Security check - ensure file is in the out directory
- if (!filePath.startsWith(path.resolve('out'))) {
- res.status(403).json({ error: 'Access denied' });
+ const filename = req.params.filename;
+
+ // Safely resolve the file path
+ const filePath = safeResolveOutPath(filename);
+ if (!filePath) {
+ res.status(403).json({ error: "Invalid filename" });
return;
}
-
+
if (!fs.existsSync(filePath)) {
- res.status(404).json({ error: 'File not found' });
+ res.status(404).json({ error: "File not found" });
return;
}
-
+
fs.unlinkSync(filePath);
console.log(`🗑️ File deleted: ${filename}`);
-
- res.json({
- success: true,
- message: `File ${filename} deleted successfully`
+
+ res.json({
+ success: true,
+ message: `File ${filename} deleted successfully`,
});
} catch (error) {
- console.error('Delete error:', error);
- res.status(500).json({ error: 'Failed to delete file' });
+ console.error("Delete error:", error);
+ res.status(500).json({ error: "Failed to delete file" });
}
});
// Health check endpoint to monitor system resources
-app.get('/health', (req, res) => {
+app.get("/health", (req, res) => {
const used = process.memoryUsage();
res.json({
- status: 'ok',
+ status: "ok",
memory: {
rss: `${Math.round(used.rss / 1024 / 1024)} MB`,
heapTotal: `${Math.round(used.heapTotal / 1024 / 1024)} MB`,
heapUsed: `${Math.round(used.heapUsed / 1024 / 1024)} MB`,
},
- uptime: `${Math.round(process.uptime())} seconds`
+ uptime: `${Math.round(process.uptime())} seconds`,
});
});
-app.post('/render', async (req, res) => {
+app.post("/render", async (req, res) => {
try {
// Get input props from POST body
const inputProps = {
@@ -282,52 +290,60 @@ app.post('/render', async (req, res) => {
await renderMedia({
composition,
serveUrl: bundleLocation,
- codec: 'h264',
+ codec: "h264",
outputLocation: `out/${compositionId}.mp4`,
inputProps,
// Optimized settings for server hardware
concurrency: 3, // Use 3 cores, leave 1 for system
verbose: true,
- logLevel: 'info', // More detailed logging for server monitoring
+ logLevel: "info", // More detailed logging for server monitoring
// Balanced encoding settings for server performance
ffmpegOverride: ({ args }) => {
return [
...args,
- '-preset', 'fast', // Good balance of speed and quality
- '-crf', '28', // Better quality than ultrafast setting
- '-threads', '3', // Use 3 threads for encoding
- '-tune', 'film', // Better quality for general content
- '-x264-params', 'ref=3:me=hex:subme=6:trellis=1', // Better quality settings
- '-g', '30', // Standard keyframe interval
- '-bf', '2', // Allow some B-frames for better compression
- '-maxrate', '5M', // Limit bitrate to prevent memory issues
- '-bufsize', '10M', // Buffer size for rate control
+ "-preset",
+ "fast", // Good balance of speed and quality
+ "-crf",
+ "28", // Better quality than ultrafast setting
+ "-threads",
+ "3", // Use 3 threads for encoding
+ "-tune",
+ "film", // Better quality for general content
+ "-x264-params",
+ "ref=3:me=hex:subme=6:trellis=1", // Better quality settings
+ "-g",
+ "30", // Standard keyframe interval
+ "-bf",
+ "2", // Allow some B-frames for better compression
+ "-maxrate",
+ "5M", // Limit bitrate to prevent memory issues
+ "-bufsize",
+ "10M", // Buffer size for rate control
];
},
timeoutInMilliseconds: 900000, // 15 minute timeout for longer videos
});
- console.log('✅ Render completed successfully');
+ console.log("✅ Render completed successfully");
res.sendFile(path.resolve(`out/${compositionId}.mp4`));
-
} catch (err) {
- console.error('❌ Render failed:', err);
+ console.error("❌ Render failed:", err);
// Clean up failed renders
try {
const outputPath = `out/${compositionId}.mp4`;
if (fs.existsSync(outputPath)) {
fs.unlinkSync(outputPath);
- console.log('🧹 Cleaned up partial file');
+ console.log("🧹 Cleaned up partial file");
}
} catch (cleanupErr) {
- console.warn('⚠️ Could not clean up:', cleanupErr);
+ console.warn("⚠️ Could not clean up:", cleanupErr);
}
res.status(500).json({
- error: 'Video rendering failed',
- message: 'Your laptop might be under heavy load. Try closing other apps and rendering again.',
- tip: 'Videos are limited to 5 seconds at half resolution for performance.'
+ error: "Video rendering failed",
+ message: "Your laptop might be under heavy load. Try closing other apps and rendering again.",
+ tip: "Videos are limited to 5 seconds at half resolution for performance.",
});
}
});
@@ -347,7 +363,5 @@ app.listen(port, () => {
console.log(` - Balanced quality/speed encoding`);
console.log(` - Full resolution rendering`);
console.log(` - 15-minute timeout for longer videos`);
- console.log(`📂 Media files are served from: ${path.resolve('out')}`);
+ console.log(`📂 Media files are served from: ${path.resolve("out")}`);
});
-
-
diff --git a/backend/main.py b/backend/main.py
index a83f9ff..b7d654f 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -2,12 +2,17 @@
from typing import Any
from dotenv import load_dotenv
+import traceback
+import json as _json
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from google import genai
from pydantic import BaseModel, ConfigDict
+import re
+from datetime import datetime, timedelta
from schema import FunctionCallResponse, MediaBinItem, TimelineState
+from tools_registry import get_tools_catalog_json
load_dotenv()
@@ -38,45 +43,360 @@ class Message(BaseModel):
chat_history: list[dict[str, Any]] | None = None # prior turns: [{"role":"user"|"assistant","content":"..."}]
+def _to_seconds(value: Any) -> float | None:
+ """Best-effort conversion of a value to seconds (float).
+
+ Supports numbers, "hh:mm[:ss]", and free-form like "1h 2m 3s", "90s", "2.5min".
+ """
+ if isinstance(value, (int, float)):
+ try:
+ v = float(value)
+ if v == v: # not NaN
+ return v
+ except Exception:
+ return None
+ return None
+ if not isinstance(value, str):
+ return None
+ s = value.strip().lower()
+ # hh:mm[:ss]
+ if ":" in s:
+ parts = [p for p in s.split(":") if p != ""]
+ try:
+ if len(parts) == 2:
+ m, sec = float(parts[0]), float(parts[1])
+ return m * 60 + sec
+ if len(parts) == 3:
+ h, m, sec = float(parts[0]), float(parts[1]), float(parts[2])
+ return h * 3600 + m * 60 + sec
+ except Exception:
+ pass
+ # 1h2m3s / 2m / 90s etc.
+ # Bounded quantifiers and longest-unit-first ordering prevent polynomial backtracking.
+ total = 0.0
+ matched = False
+ for m in re.finditer(r"(?P[0-9]{1,15}(?:\.[0-9]{1,10})?)[ ]?(?Pmilliseconds|millisecond|minutes|minute|seconds|second|hours|hour|secs|mins|hrs|min|sec|ms|hr|m|s|h)\b",
+ s):
+ matched = True
+ num = float(m.group("num"))
+ unit = m.group("unit")
+ if unit in {"h", "hr", "hrs", "hour", "hours"}:
+ total += num * 3600
+ elif unit in {"m", "min", "mins", "minute", "minutes"}:
+ total += num * 60
+ elif unit in {"ms", "millisecond", "milliseconds"}:
+ total += num / 1000.0
+ else: # seconds
+ total += num
+ if matched:
+ return total
+ # plain number fallback
+ try:
+ v = float(s)
+ return v if v == v else None
+ except Exception:
+ return None
+
+
+def _normalize_time_fields_from_text(user_text: str, args: dict[str, Any]) -> dict[str, Any]:
+ """Fill or fix start_seconds / duration_seconds / end_seconds from the user text.
+
+ - Detect patterns like "from 2s to 12s", "at 2s", "for 10s", "span for 10s".
+ - Also detect variants like "to 12 seconds long", "12s long", "set to 12s", "make it 12s".
+ - If duration and start are present but end is not, compute end.
+ - If end and start present but duration absent, compute duration.
+ """
+ updated = dict(args or {})
+ text = (user_text or "").lower()
+
+ # Extract explicit FROM ... TO ... first
+ # Use [0-9][0-9.]*[a-z]*(?:[ ][a-z]{1,12})? so the numeric, attached-unit, and
+ # separated-unit character classes are disjoint — no ambiguous matching, no ReDoS.
+ m = re.search(r"from\s+([0-9][0-9.]*[a-z]*(?:[ ][a-z]{1,12})?)\s+to\s+([0-9][0-9.]*[a-z]*(?:[ ][a-z]{1,12})?)", text)
+ if m:
+ start_candidate = _to_seconds(m.group(1))
+ end_candidate = _to_seconds(m.group(2))
+ if start_candidate is not None and updated.get("start_seconds") is None:
+ updated["start_seconds"] = start_candidate
+ if end_candidate is not None and updated.get("end_seconds") is None:
+ updated["end_seconds"] = end_candidate
+
+ # AT ... / START AT ... / FROM ... (single)
+ m2 = re.search(r"(?:at|starting\s+at|start\s+at|from)\s+([0-9][0-9.]*[a-z]*(?:[ ][a-z]{1,12})?)", text)
+ if m2 and updated.get("start_seconds") is None:
+ start_candidate = _to_seconds(m2.group(1))
+ if start_candidate is not None:
+ updated["start_seconds"] = start_candidate
+
+ # FOR ... / SPAN FOR ...
+ m3 = re.search(r"(?:for|span(?:s)?\s+for)\s+([0-9][0-9.]*[a-z]*(?:[ ][a-z]{1,12})?)", text)
+ if m3 and updated.get("duration_seconds") is None:
+ dur_candidate = _to_seconds(m3.group(1))
+ if dur_candidate is not None:
+ updated["duration_seconds"] = dur_candidate
+
+ # TO ... LONG / SET TO ... / MAKE (IT)? ...
+ # Examples: "to 12 seconds long", "set to 12s", "make it 8 sec", "12s long"
+ if updated.get("duration_seconds") is None:
+ m4 = re.search(r"(?:to\s+)?([0-9][0-9.]*[a-z]*(?:[ ][a-z]{1,12})?)\s+long", text)
+ if m4:
+ dur_candidate = _to_seconds(m4.group(1))
+ if dur_candidate is not None:
+ updated["duration_seconds"] = dur_candidate
+ if updated.get("duration_seconds") is None:
+ m5 = re.search(r"(?:set\s+(?:it\s+)?to|make\s+(?:it\s+)?)\s+([0-9][0-9.]*[a-z]*(?:[ ][a-z]{1,12})?)", text)
+ if m5:
+ dur_candidate = _to_seconds(m5.group(1))
+ if dur_candidate is not None:
+ updated["duration_seconds"] = dur_candidate
+
+ # Post-derivations
+ start_val = _to_seconds(updated.get("start_seconds"))
+ end_val = _to_seconds(updated.get("end_seconds"))
+ dur_val = _to_seconds(updated.get("duration_seconds"))
+ if start_val is not None and end_val is not None and dur_val is None:
+ updated["duration_seconds"] = max(0.0, end_val - start_val)
+ if start_val is not None and dur_val is not None and end_val is None:
+ updated["end_seconds"] = max(0.0, start_val + dur_val)
+
+ return updated
+
+
+def _postprocess_response(user_text: str, resp: FunctionCallResponse) -> FunctionCallResponse:
+ if resp and resp.function_call and isinstance(resp.function_call.arguments, dict):
+ resp.function_call.arguments = _normalize_time_fields_from_text(user_text, resp.function_call.arguments)
+ return resp
+
+
+def _second_pass_force_tool(request: Message, assistant_note: str) -> FunctionCallResponse | None:
+ """If the first pass returned only assistant text, try a second pass that
+ explicitly asks for a single tool call when possible."""
+ try:
+ response_schema = {
+ "type": "object",
+ "properties": {
+ "function_call": {
+ "type": "object",
+ "properties": {
+ "function_name": {"type": "string"},
+ "arguments": {"type": "object", "properties": {}},
+ },
+ "required": ["function_name"],
+ },
+ "assistant_message": {"type": "string"},
+ },
+ }
+ response = gemini_api.models.generate_content(
+ model="gemini-2.5-flash",
+ contents=f"""You previously drafted a plan:\n\n{assistant_note}\n\nNow convert the user's latest instruction into exactly one tool call if applicable.\nReturn strictly a JSON object with either function_call or assistant_message.\nAvailable tools:\n{get_tools_catalog_json()}\n\nUser message: {request.message}\nTimeline state: {request.timeline_state}\nMedia bin items: {request.mediabin_items}\n""",
+ config={
+ "response_mime_type": "application/json",
+ "response_schema": response_schema,
+ },
+ )
+ text_payload = getattr(response, "text", None)
+ if text_payload:
+ data = _json.loads(text_payload)
+ return FunctionCallResponse.model_validate(data)
+ except Exception as e:
+ print("[AI] Second-pass error:", repr(e))
+ return None
+
+
@app.post("/ai")
async def process_ai_message(request: Message) -> FunctionCallResponse:
- print(FunctionCallResponse)
try:
+ if not GEMINI_API_KEY:
+ raise HTTPException(status_code=500, detail="GEMINI_API_KEY is not set in environment")
+
+ # Debug: incoming request summary
+ try:
+ print(
+ "[AI] Incoming payload summary:",
+ {
+ "message": request.message[:200] if request.message else None,
+ "mentioned_scrubber_ids": request.mentioned_scrubber_ids,
+ "timeline_state_present": request.timeline_state is not None,
+ "mediabin_count": len(request.mediabin_items or []),
+ "chat_history_count": len(request.chat_history or []),
+ },
+ )
+ except Exception:
+ pass
+
+ # Minimal Gemini-compatible response schema (no additionalProperties, no unions)
+ response_schema = {
+ "type": "object",
+ "properties": {
+ "function_call": {
+ "type": "object",
+ "properties": {
+ "function_name": {"type": "string"},
+ "arguments": {
+ "type": "object",
+ "properties": {
+ # Generic placement/move
+ "scrubber_id": {"type": "string"},
+ "track_number": {"type": "integer"},
+ "track_id": {"type": "string"},
+ "start_seconds": {"type": "number"},
+ "position_seconds": {"type": "number"},
+ "drop_left_px": {"type": "integer"},
+ "duration_seconds": {"type": "number"},
+ "end_seconds": {"type": "number"},
+ "pixels_per_second": {"type": "integer"},
+ "scrubber_name": {"type": "string"},
+ "new_position_seconds": {"type": "number"},
+ "new_track_number": {"type": "integer"},
+ "scrubber_ids": {"type": "array", "items": {"type": "string"}},
+ "offset_seconds": {"type": "number"},
+ # Text styling/content
+ "new_text_content": {"type": "string"},
+ "fontSize": {"type": "integer"},
+ "fontFamily": {"type": "string"},
+ "color": {"type": "string"},
+ "textAlign": {"type": "string"},
+ "fontWeight": {"type": "string"},
+ # Composition settings
+ "width": {"type": "integer"},
+ "height": {"type": "integer"},
+ "auto": {"type": "boolean"},
+ },
+ },
+ },
+ "required": ["function_name"],
+ },
+ "assistant_message": {"type": "string"},
+ },
+ }
+
response = gemini_api.models.generate_content(
- model="gemini-2.5-flash",
- contents=f"""
- You are Kimu, an AI assistant inside a video editor. You can decide to either:
- - call ONE tool from the provided schema when the user explicitly asks for an editing action, or
- - return a short friendly assistant_message when no concrete action is needed (e.g., greetings, small talk, clarifying questions).
+ model="gemini-2.5-flash",
+ contents=f"""You are Kimu, an AI assistant inside a video editor.
- Strictly follow:
- - If the user's message does not clearly request an editing action, set function_call to null and include an assistant_message.
- - Only produce a function_call when it is safe and unambiguous to execute.
+ You will return a JSON object with either:
+ - function_call: {{"function_name": string, "arguments": object}} [V2 universal schema]
+ - assistant_message: string (when no action is needed or a clarification is required)
- Inference rules:
+ Available tools (names and schemas):
+ {get_tools_catalog_json()}
+
+ Tool calling policy:
+ - Call ONE tool only when the user's request is clear and safe to execute.
+ - If ambiguous (e.g., no clear asset or time), return an assistant_message that asks a concise clarifying question.
- Assume a single active timeline; do NOT require a timeline_id.
- - Tracks are named like "track-1", but when the user says "track 1" they mean number 1.
- - Use pixels_per_second=100 by default if not provided.
- - When the user names media like "twitter" or "twitter header", map that to the closest media in the media bin by name substring match.
- - Prefer LLMAddScrubberByName when the user specifies a name, track number, and time in seconds.
- - If the user asks to remove scrubbers in a specific track, call LLMDeleteScrubbersInTrack with that track number.
-
- Conversation so far (oldest first): {request.chat_history}
-
- User message: {request.message}
- Mentioned scrubber ids: {request.mentioned_scrubber_ids}
- Timeline state: {request.timeline_state}
- Media bin items: {request.mediabin_items}
- """,
- config={
- "response_mime_type": "application/json",
- "response_schema": FunctionCallResponse,
- },
+ - Tracks are named like "track-1", but users say "track 1" meaning 1-based index.
+ - Default pixels_per_second = 100 if not provided.
+ - If user mentions items with @, prefer those exact assets (via mentioned_scrubber_ids). Otherwise, map names by case-insensitive substring to media bin items.
+
+ Editing semantics for time and duration:
+ - "at 2 sec" or "at 2s" → start_seconds = 2.
+ - "for 10 sec" → duration_seconds = 10.
+ - "from 2 sec for 10 sec" → start_seconds = 2, duration_seconds = 10.
+ - "from 2 sec to 12 sec" → start_seconds = 2, end_seconds = 12.
+ - If duration is omitted, use the media's intrinsic duration if available; for images default to 5 seconds.
+
+
+
+ Tool selection guidance:
+ - If the user references @, call AddMediaById using mentioned_scrubber_ids[0].
+ - If the user references an asset by name (e.g., "cardboard"), call AddMediaByName with scrubber_name="cardboard".
+ - If user asks to make it span for N seconds, prefer AddMedia* with duration_seconds.
+ - If user says "from A sec to B sec", pass start_seconds=A and end_seconds=B.
+ - For deletions like "remove everything on track 2", call DeleteScrubbersInTrack with track_number=2.
+
+ Conversation so far (oldest first): {request.chat_history}
+
+ User message: {request.message}
+ Mentioned scrubber ids: {request.mentioned_scrubber_ids}
+ Timeline state: {request.timeline_state}
+ Media bin items: {request.mediabin_items}
+ """,
+ config={
+ "response_mime_type": "application/json",
+ "response_schema": response_schema,
+ },
+
)
- print(response)
+ # Debug: response object summary
+ try:
+ print("[AI] Raw response type:", type(response))
+ # Some SDK versions expose .to_dict() or .candidates; print defensively
+ cand = getattr(response, "candidates", None)
+ if cand is not None:
+ print("[AI] candidates len:", len(cand))
+ text_preview = getattr(response, "text", None)
+ if isinstance(text_preview, str):
+ print("[AI] text preview:", text_preview[:200])
+ except Exception:
+ pass
+
+ # Robust parsing across SDK versions
+ parsed = getattr(response, "parsed", None)
+ if parsed is not None:
+ try:
+ if isinstance(parsed, dict):
+ resp = FunctionCallResponse.model_validate(parsed)
+ return _postprocess_response(request.message, resp)
+ # Some SDKs may return an object with attribute access
+ maybe_name = getattr(parsed, "function_call", None)
+ maybe_msg = getattr(parsed, "assistant_message", None)
+ if maybe_name is not None or maybe_msg is not None:
+ as_dict = {}
+ if maybe_name is not None:
+ # function_call may itself be an object with attrs
+ fn = getattr(parsed.function_call, "function_name", None)
+ args = getattr(parsed.function_call, "arguments", None)
+ if args is None:
+ # also try dict-like
+ args = getattr(parsed.function_call, "get", lambda k, d=None: None)("arguments", None)
+ as_dict["function_call"] = {
+ "function_name": fn,
+ "arguments": args or {},
+ }
+ if maybe_msg is not None:
+ as_dict["assistant_message"] = maybe_msg
+ if as_dict:
+ resp = FunctionCallResponse.model_validate(as_dict)
+ # If no tool chosen, attempt a second pass
+ if resp.function_call is None and resp.assistant_message:
+ forced = _second_pass_force_tool(request, resp.assistant_message)
+ if forced is not None:
+ return _postprocess_response(request.message, forced)
+ return _postprocess_response(request.message, resp)
+ except Exception:
+ pass
+
+ # Fallback: try JSON text
+ text_payload = getattr(response, "text", None)
+ if text_payload:
+ try:
+ data = _json.loads(text_payload)
+ resp = FunctionCallResponse.model_validate(data)
+ # If no tool chosen, try second pass using the assistant text
+ if resp.function_call is None and resp.assistant_message:
+ forced = _second_pass_force_tool(request, resp.assistant_message)
+ if forced is not None:
+ return _postprocess_response(request.message, forced)
+ return _postprocess_response(request.message, resp)
+ except Exception as e: # noqa: BLE001
+ raise HTTPException(status_code=500, detail=f"Invalid JSON from model: {e}") from e
+
+ # As a last resort, try to serialize the response to dict if available
+ try:
+ to_dict = getattr(response, "to_dict", None)
+ if callable(to_dict):
+ data = to_dict()
+ resp = FunctionCallResponse.model_validate(data)
+ return _postprocess_response(request.message, resp)
+ except Exception:
+ pass
- return FunctionCallResponse.model_validate(response.parsed)
+ raise HTTPException(status_code=500, detail="Model returned no parseable content; enable debug logs for details")
except Exception as e:
+ # Print full traceback for debugging
+ print("[AI] Error:", repr(e))
+ traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e)) from e
diff --git a/backend/poetry.lock b/backend/poetry.lock
new file mode 100644
index 0000000..faba0f7
--- /dev/null
+++ b/backend/poetry.lock
@@ -0,0 +1,1653 @@
+# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+description = "Reusable constraint types to use with typing.Annotated"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
+ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
+]
+
+[[package]]
+name = "anyio"
+version = "4.10.0"
+description = "High-level concurrency and networking framework on top of asyncio or Trio"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"},
+ {file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"},
+]
+
+[package.dependencies]
+idna = ">=2.8"
+sniffio = ">=1.1"
+typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
+
+[package.extras]
+trio = ["trio (>=0.26.1)"]
+
+[[package]]
+name = "cachetools"
+version = "5.5.2"
+description = "Extensible memoizing collections and decorators"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a"},
+ {file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"},
+]
+
+[[package]]
+name = "certifi"
+version = "2025.8.3"
+description = "Python package for providing Mozilla's CA Bundle."
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"},
+ {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"},
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.3"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-win32.whl", hash = "sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca"},
+ {file = "charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a"},
+ {file = "charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14"},
+]
+
+[[package]]
+name = "click"
+version = "8.2.1"
+description = "Composable command line interface toolkit"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"},
+ {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+groups = ["main"]
+markers = "platform_system == \"Windows\" or sys_platform == \"win32\""
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "dnspython"
+version = "2.7.0"
+description = "DNS toolkit"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"},
+ {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"},
+]
+
+[package.extras]
+dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme (>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"]
+dnssec = ["cryptography (>=43)"]
+doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"]
+doq = ["aioquic (>=1.0.0)"]
+idna = ["idna (>=3.7)"]
+trio = ["trio (>=0.23)"]
+wmi = ["wmi (>=1.5.1)"]
+
+[[package]]
+name = "email-validator"
+version = "2.3.0"
+description = "A robust email address syntax and deliverability validation library."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"},
+ {file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"},
+]
+
+[package.dependencies]
+dnspython = ">=2.0.0"
+idna = ">=2.0.0"
+
+[[package]]
+name = "fastapi"
+version = "0.116.1"
+description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565"},
+ {file = "fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143"},
+]
+
+[package.dependencies]
+email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"standard\""}
+fastapi-cli = {version = ">=0.0.8", extras = ["standard"], optional = true, markers = "extra == \"standard\""}
+httpx = {version = ">=0.23.0", optional = true, markers = "extra == \"standard\""}
+jinja2 = {version = ">=3.1.5", optional = true, markers = "extra == \"standard\""}
+pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
+python-multipart = {version = ">=0.0.18", optional = true, markers = "extra == \"standard\""}
+starlette = ">=0.40.0,<0.48.0"
+typing-extensions = ">=4.8.0"
+uvicorn = {version = ">=0.12.0", extras = ["standard"], optional = true, markers = "extra == \"standard\""}
+
+[package.extras]
+all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
+standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
+standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
+
+[[package]]
+name = "fastapi-cli"
+version = "0.0.10"
+description = "Run and manage FastAPI apps from the command line with FastAPI CLI. 🚀"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "fastapi_cli-0.0.10-py3-none-any.whl", hash = "sha256:04bef56b49f7357c6c4acd4f793b4433ed3f511be431ed0af68db6d3f8bd44b3"},
+ {file = "fastapi_cli-0.0.10.tar.gz", hash = "sha256:85a93df72ff834c3d2a356164512cabaf8f093d50eddad9309065a9c9ac5193a"},
+]
+
+[package.dependencies]
+fastapi-cloud-cli = {version = ">=0.1.1", optional = true, markers = "extra == \"standard\""}
+rich-toolkit = ">=0.14.8"
+typer = ">=0.15.1"
+uvicorn = {version = ">=0.15.0", extras = ["standard"]}
+
+[package.extras]
+standard = ["fastapi-cloud-cli (>=0.1.1)", "uvicorn[standard] (>=0.15.0)"]
+standard-no-fastapi-cloud-cli = ["uvicorn[standard] (>=0.15.0)"]
+
+[[package]]
+name = "fastapi-cloud-cli"
+version = "0.1.5"
+description = "Deploy and manage FastAPI Cloud apps from the command line 🚀"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "fastapi_cloud_cli-0.1.5-py3-none-any.whl", hash = "sha256:d80525fb9c0e8af122370891f9fa83cf5d496e4ad47a8dd26c0496a6c85a012a"},
+ {file = "fastapi_cloud_cli-0.1.5.tar.gz", hash = "sha256:341ee585eb731a6d3c3656cb91ad38e5f39809bf1a16d41de1333e38635a7937"},
+]
+
+[package.dependencies]
+httpx = ">=0.27.0"
+pydantic = {version = ">=1.6.1", extras = ["email"]}
+rich-toolkit = ">=0.14.5"
+rignore = ">=0.5.1"
+sentry-sdk = ">=2.20.0"
+typer = ">=0.12.3"
+uvicorn = {version = ">=0.15.0", extras = ["standard"]}
+
+[package.extras]
+standard = ["uvicorn[standard] (>=0.15.0)"]
+
+[[package]]
+name = "google-auth"
+version = "2.40.3"
+description = "Google Authentication Library"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca"},
+ {file = "google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77"},
+]
+
+[package.dependencies]
+cachetools = ">=2.0.0,<6.0"
+pyasn1-modules = ">=0.2.1"
+rsa = ">=3.1.4,<5"
+
+[package.extras]
+aiohttp = ["aiohttp (>=3.6.2,<4.0.0)", "requests (>=2.20.0,<3.0.0)"]
+enterprise-cert = ["cryptography", "pyopenssl"]
+pyjwt = ["cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "pyjwt (>=2.0)"]
+pyopenssl = ["cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"]
+reauth = ["pyu2f (>=0.1.5)"]
+requests = ["requests (>=2.20.0,<3.0.0)"]
+testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.6.2,<4.0.0)", "aioresponses", "cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "flask", "freezegun", "grpcio", "mock", "oauth2client", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"]
+urllib3 = ["packaging", "urllib3"]
+
+[[package]]
+name = "google-genai"
+version = "1.33.0"
+description = "GenAI Python SDK"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "google_genai-1.33.0-py3-none-any.whl", hash = "sha256:1710e958af0a0f3d19521fabbefd86b22d1f212376103f18fed11c9d96fa48e8"},
+ {file = "google_genai-1.33.0.tar.gz", hash = "sha256:7d3a5ebad712d95a0d1775842505886eb43cc52f9f478aa4ab0e2d25412499a2"},
+]
+
+[package.dependencies]
+anyio = ">=4.8.0,<5.0.0"
+google-auth = ">=2.14.1,<3.0.0"
+httpx = ">=0.28.1,<1.0.0"
+pydantic = ">=2.0.0,<3.0.0"
+requests = ">=2.28.1,<3.0.0"
+tenacity = ">=8.2.3,<9.2.0"
+typing-extensions = ">=4.11.0,<5.0.0"
+websockets = ">=13.0.0,<15.1.0"
+
+[package.extras]
+aiohttp = ["aiohttp (<4.0.0)"]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
+ {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+description = "A minimal low-level HTTP client."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
+ {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
+]
+
+[package.dependencies]
+certifi = "*"
+h11 = ">=0.16"
+
+[package.extras]
+asyncio = ["anyio (>=4.0,<5.0)"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (==1.*)"]
+trio = ["trio (>=0.22.0,<1.0)"]
+
+[[package]]
+name = "httptools"
+version = "0.6.4"
+description = "A collection of framework independent HTTP protocol utils."
+optional = false
+python-versions = ">=3.8.0"
+groups = ["main"]
+files = [
+ {file = "httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0"},
+ {file = "httptools-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:345c288418f0944a6fe67be8e6afa9262b18c7626c3ef3c28adc5eabc06a68da"},
+ {file = "httptools-0.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deee0e3343f98ee8047e9f4c5bc7cedbf69f5734454a94c38ee829fb2d5fa3c1"},
+ {file = "httptools-0.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca80b7485c76f768a3bc83ea58373f8db7b015551117375e4918e2aa77ea9b50"},
+ {file = "httptools-0.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:90d96a385fa941283ebd231464045187a31ad932ebfa541be8edf5b3c2328959"},
+ {file = "httptools-0.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:59e724f8b332319e2875efd360e61ac07f33b492889284a3e05e6d13746876f4"},
+ {file = "httptools-0.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:c26f313951f6e26147833fc923f78f95604bbec812a43e5ee37f26dc9e5a686c"},
+ {file = "httptools-0.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f47f8ed67cc0ff862b84a1189831d1d33c963fb3ce1ee0c65d3b0cbe7b711069"},
+ {file = "httptools-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0614154d5454c21b6410fdf5262b4a3ddb0f53f1e1721cfd59d55f32138c578a"},
+ {file = "httptools-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8787367fbdfccae38e35abf7641dafc5310310a5987b689f4c32cc8cc3ee975"},
+ {file = "httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b0f7fe4fd38e6a507bdb751db0379df1e99120c65fbdc8ee6c1d044897a636"},
+ {file = "httptools-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40a5ec98d3f49904b9fe36827dcf1aadfef3b89e2bd05b0e35e94f97c2b14721"},
+ {file = "httptools-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dacdd3d10ea1b4ca9df97a0a303cbacafc04b5cd375fa98732678151643d4988"},
+ {file = "httptools-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:288cd628406cc53f9a541cfaf06041b4c71d751856bab45e3702191f931ccd17"},
+ {file = "httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2"},
+ {file = "httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44"},
+ {file = "httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1"},
+ {file = "httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2"},
+ {file = "httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81"},
+ {file = "httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f"},
+ {file = "httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970"},
+ {file = "httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660"},
+ {file = "httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083"},
+ {file = "httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3"},
+ {file = "httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071"},
+ {file = "httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5"},
+ {file = "httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0"},
+ {file = "httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8"},
+ {file = "httptools-0.6.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:d3f0d369e7ffbe59c4b6116a44d6a8eb4783aae027f2c0b366cf0aa964185dba"},
+ {file = "httptools-0.6.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:94978a49b8f4569ad607cd4946b759d90b285e39c0d4640c6b36ca7a3ddf2efc"},
+ {file = "httptools-0.6.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40dc6a8e399e15ea525305a2ddba998b0af5caa2566bcd79dcbe8948181eeaff"},
+ {file = "httptools-0.6.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab9ba8dcf59de5181f6be44a77458e45a578fc99c31510b8c65b7d5acc3cf490"},
+ {file = "httptools-0.6.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:fc411e1c0a7dcd2f902c7c48cf079947a7e65b5485dea9decb82b9105ca71a43"},
+ {file = "httptools-0.6.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:d54efd20338ac52ba31e7da78e4a72570cf729fac82bc31ff9199bedf1dc7440"},
+ {file = "httptools-0.6.4-cp38-cp38-win_amd64.whl", hash = "sha256:df959752a0c2748a65ab5387d08287abf6779ae9165916fe053e68ae1fbdc47f"},
+ {file = "httptools-0.6.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:85797e37e8eeaa5439d33e556662cc370e474445d5fab24dcadc65a8ffb04003"},
+ {file = "httptools-0.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:db353d22843cf1028f43c3651581e4bb49374d85692a85f95f7b9a130e1b2cab"},
+ {file = "httptools-0.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1ffd262a73d7c28424252381a5b854c19d9de5f56f075445d33919a637e3547"},
+ {file = "httptools-0.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:703c346571fa50d2e9856a37d7cd9435a25e7fd15e236c397bf224afaa355fe9"},
+ {file = "httptools-0.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aafe0f1918ed07b67c1e838f950b1c1fabc683030477e60b335649b8020e1076"},
+ {file = "httptools-0.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0e563e54979e97b6d13f1bbc05a96109923e76b901f786a5eae36e99c01237bd"},
+ {file = "httptools-0.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:b799de31416ecc589ad79dd85a0b2657a8fe39327944998dea368c1d4c9e55e6"},
+ {file = "httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c"},
+]
+
+[package.extras]
+test = ["Cython (>=0.29.24)"]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+description = "The next generation HTTP client."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
+ {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
+]
+
+[package.dependencies]
+anyio = "*"
+certifi = "*"
+httpcore = "==1.*"
+idna = "*"
+
+[package.extras]
+brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
+cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (==1.*)"]
+zstd = ["zstandard (>=0.18.0)"]
+
+[[package]]
+name = "idna"
+version = "3.10"
+description = "Internationalized Domain Names in Applications (IDNA)"
+optional = false
+python-versions = ">=3.6"
+groups = ["main"]
+files = [
+ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
+ {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
+]
+
+[package.extras]
+all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+description = "A very fast and expressive template engine."
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
+ {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
+]
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.0.0"
+description = "Python port of markdown-it. Markdown parsing, done right!"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"},
+ {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"},
+]
+
+[package.dependencies]
+mdurl = ">=0.1,<1.0"
+
+[package.extras]
+benchmarking = ["psutil", "pytest", "pytest-benchmark"]
+compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"]
+linkify = ["linkify-it-py (>=1,<3)"]
+plugins = ["mdit-py-plugins (>=0.5.0)"]
+profiling = ["gprof2dot"]
+rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"]
+testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.2"
+description = "Safely add untrusted strings to HTML/XML markup."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"},
+ {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"},
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+description = "Markdown URL utilities"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
+ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
+]
+
+[[package]]
+name = "mypy"
+version = "1.17.1"
+description = "Optional static typing for Python"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "mypy-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3fbe6d5555bf608c47203baa3e72dbc6ec9965b3d7c318aa9a4ca76f465bd972"},
+ {file = "mypy-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80ef5c058b7bce08c83cac668158cb7edea692e458d21098c7d3bce35a5d43e7"},
+ {file = "mypy-1.17.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a580f8a70c69e4a75587bd925d298434057fe2a428faaf927ffe6e4b9a98df"},
+ {file = "mypy-1.17.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd86bb649299f09d987a2eebb4d52d10603224500792e1bee18303bbcc1ce390"},
+ {file = "mypy-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a76906f26bd8d51ea9504966a9c25419f2e668f012e0bdf3da4ea1526c534d94"},
+ {file = "mypy-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:e79311f2d904ccb59787477b7bd5d26f3347789c06fcd7656fa500875290264b"},
+ {file = "mypy-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad37544be07c5d7fba814eb370e006df58fed8ad1ef33ed1649cb1889ba6ff58"},
+ {file = "mypy-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:064e2ff508e5464b4bd807a7c1625bc5047c5022b85c70f030680e18f37273a5"},
+ {file = "mypy-1.17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70401bbabd2fa1aa7c43bb358f54037baf0586f41e83b0ae67dd0534fc64edfd"},
+ {file = "mypy-1.17.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e92bdc656b7757c438660f775f872a669b8ff374edc4d18277d86b63edba6b8b"},
+ {file = "mypy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c1fdf4abb29ed1cb091cf432979e162c208a5ac676ce35010373ff29247bcad5"},
+ {file = "mypy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:ff2933428516ab63f961644bc49bc4cbe42bbffb2cd3b71cc7277c07d16b1a8b"},
+ {file = "mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb"},
+ {file = "mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403"},
+ {file = "mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056"},
+ {file = "mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341"},
+ {file = "mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb"},
+ {file = "mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19"},
+ {file = "mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7"},
+ {file = "mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81"},
+ {file = "mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6"},
+ {file = "mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849"},
+ {file = "mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14"},
+ {file = "mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a"},
+ {file = "mypy-1.17.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733"},
+ {file = "mypy-1.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd"},
+ {file = "mypy-1.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0"},
+ {file = "mypy-1.17.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a"},
+ {file = "mypy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91"},
+ {file = "mypy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed"},
+ {file = "mypy-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5d1092694f166a7e56c805caaf794e0585cabdbf1df36911c414e4e9abb62ae9"},
+ {file = "mypy-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79d44f9bfb004941ebb0abe8eff6504223a9c1ac51ef967d1263c6572bbebc99"},
+ {file = "mypy-1.17.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b01586eed696ec905e61bd2568f48740f7ac4a45b3a468e6423a03d3788a51a8"},
+ {file = "mypy-1.17.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43808d9476c36b927fbcd0b0255ce75efe1b68a080154a38ae68a7e62de8f0f8"},
+ {file = "mypy-1.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:feb8cc32d319edd5859da2cc084493b3e2ce5e49a946377663cc90f6c15fb259"},
+ {file = "mypy-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d7598cf74c3e16539d4e2f0b8d8c318e00041553d83d4861f87c7a72e95ac24d"},
+ {file = "mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9"},
+ {file = "mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01"},
+]
+
+[package.dependencies]
+mypy_extensions = ">=1.0.0"
+pathspec = ">=0.9.0"
+typing_extensions = ">=4.6.0"
+
+[package.extras]
+dmypy = ["psutil (>=4.0)"]
+faster-cache = ["orjson"]
+install-types = ["pip"]
+mypyc = ["setuptools (>=50)"]
+reports = ["lxml"]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+description = "Type system extensions for programs checked with the mypy type checker."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
+ {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
+]
+
+[[package]]
+name = "parsedatetime"
+version = "2.6"
+description = "Parse human-readable date/time text."
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "parsedatetime-2.6-py3-none-any.whl", hash = "sha256:cb96edd7016872f58479e35879294258c71437195760746faffedb692aef000b"},
+ {file = "parsedatetime-2.6.tar.gz", hash = "sha256:4cb368fbb18a0b7231f4d76119165451c8d2e35951455dfee97c62a87b04d455"},
+]
+
+[[package]]
+name = "pathspec"
+version = "0.12.1"
+description = "Utility library for gitignore style pattern matching of file paths."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
+ {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
+]
+
+[[package]]
+name = "pyasn1"
+version = "0.6.1"
+description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"},
+ {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
+]
+
+[[package]]
+name = "pyasn1-modules"
+version = "0.4.2"
+description = "A collection of ASN.1-based protocols modules"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"},
+ {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"},
+]
+
+[package.dependencies]
+pyasn1 = ">=0.6.1,<0.7.0"
+
+[[package]]
+name = "pydantic"
+version = "2.11.7"
+description = "Data validation using Python type hints"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"},
+ {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"},
+]
+
+[package.dependencies]
+annotated-types = ">=0.6.0"
+email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""}
+pydantic-core = "2.33.2"
+typing-extensions = ">=4.12.2"
+typing-inspection = ">=0.4.0"
+
+[package.extras]
+email = ["email-validator (>=2.0.0)"]
+timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
+
+[[package]]
+name = "pydantic-core"
+version = "2.33.2"
+description = "Core functionality for Pydantic validation and serialization"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"},
+ {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"},
+ {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"},
+ {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"},
+ {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"},
+ {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"},
+ {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"},
+ {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"},
+ {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"},
+ {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"},
+ {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"},
+ {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"},
+ {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"},
+ {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"},
+ {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"},
+ {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"},
+ {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"},
+ {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"},
+ {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"},
+ {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"},
+ {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"},
+ {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"},
+ {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"},
+ {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"},
+ {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"},
+ {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"},
+ {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"},
+ {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"},
+ {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"},
+ {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"},
+ {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"},
+ {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"},
+ {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"},
+ {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"},
+ {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"},
+ {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"},
+ {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"},
+ {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"},
+ {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"},
+ {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"},
+ {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"},
+ {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"},
+ {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"},
+ {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"},
+ {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"},
+ {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"},
+ {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"},
+ {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"},
+ {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"},
+ {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"},
+ {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"},
+ {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"},
+ {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"},
+ {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"},
+ {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"},
+ {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"},
+ {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"},
+ {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"},
+ {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"},
+ {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"},
+ {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"},
+ {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"},
+ {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"},
+ {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"},
+ {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"},
+ {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"},
+ {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"},
+ {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"},
+ {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"},
+ {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"},
+ {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"},
+ {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"},
+ {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"},
+ {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"},
+ {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"},
+ {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"},
+ {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"},
+ {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"},
+ {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"},
+ {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"},
+ {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"},
+ {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"},
+ {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"},
+ {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"},
+ {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"},
+ {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"},
+ {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"},
+ {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"},
+ {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"},
+ {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"},
+ {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"},
+ {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"},
+ {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"},
+ {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"},
+ {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"},
+ {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"},
+ {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"},
+ {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"},
+ {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"},
+]
+
+[package.dependencies]
+typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
+
+[[package]]
+name = "pygments"
+version = "2.19.2"
+description = "Pygments is a syntax highlighting package written in Python."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
+ {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
+]
+
+[package.extras]
+windows-terminal = ["colorama (>=0.4.6)"]
+
+[[package]]
+name = "python-dotenv"
+version = "1.1.1"
+description = "Read key-value pairs from a .env file and set them as environment variables"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"},
+ {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"},
+]
+
+[package.extras]
+cli = ["click (>=5.0)"]
+
+[[package]]
+name = "python-multipart"
+version = "0.0.20"
+description = "A streaming multipart parser for Python"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"},
+ {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"},
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.2"
+description = "YAML parser and emitter for Python"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
+ {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
+ {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"},
+ {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"},
+ {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"},
+ {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"},
+ {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"},
+ {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"},
+ {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"},
+ {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"},
+ {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"},
+ {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"},
+ {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"},
+ {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"},
+ {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"},
+ {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"},
+ {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"},
+ {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"},
+ {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"},
+ {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"},
+ {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"},
+ {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"},
+ {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"},
+ {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"},
+ {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"},
+ {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"},
+ {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"},
+ {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"},
+ {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"},
+ {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"},
+ {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"},
+ {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"},
+ {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"},
+ {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"},
+ {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"},
+ {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"},
+ {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"},
+ {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"},
+ {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"},
+ {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"},
+ {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"},
+ {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"},
+ {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"},
+ {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"},
+ {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"},
+ {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"},
+ {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"},
+ {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"},
+ {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"},
+ {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"},
+ {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"},
+ {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"},
+ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
+]
+
+[[package]]
+name = "requests"
+version = "2.32.5"
+description = "Python HTTP for Humans."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
+ {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
+]
+
+[package.dependencies]
+certifi = ">=2017.4.17"
+charset_normalizer = ">=2,<4"
+idna = ">=2.5,<4"
+urllib3 = ">=1.21.1,<3"
+
+[package.extras]
+socks = ["PySocks (>=1.5.6,!=1.5.7)"]
+use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
+
+[[package]]
+name = "rich"
+version = "14.1.0"
+description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
+optional = false
+python-versions = ">=3.8.0"
+groups = ["main"]
+files = [
+ {file = "rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f"},
+ {file = "rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8"},
+]
+
+[package.dependencies]
+markdown-it-py = ">=2.2.0"
+pygments = ">=2.13.0,<3.0.0"
+
+[package.extras]
+jupyter = ["ipywidgets (>=7.5.1,<9)"]
+
+[[package]]
+name = "rich-toolkit"
+version = "0.15.1"
+description = "Rich toolkit for building command-line applications"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "rich_toolkit-0.15.1-py3-none-any.whl", hash = "sha256:36a0b1d9a135d26776e4b78f1d5c2655da6e0ef432380b5c6b523c8d8ab97478"},
+ {file = "rich_toolkit-0.15.1.tar.gz", hash = "sha256:6f9630eb29f3843d19d48c3bd5706a086d36d62016687f9d0efa027ddc2dd08a"},
+]
+
+[package.dependencies]
+click = ">=8.1.7"
+rich = ">=13.7.1"
+typing-extensions = ">=4.12.2"
+
+[[package]]
+name = "rignore"
+version = "0.6.4"
+description = "Python Bindings for the ignore crate"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "rignore-0.6.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c201375cfe76e56e61fcdfe50d0882aafb49544b424bfc828e0508dc9fbc431b"},
+ {file = "rignore-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4962d537e377394292c4828e1e9c620618dd8daa49ba746abe533733a89f8644"},
+ {file = "rignore-0.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a6dd2f213cff6ca3c4d257fa3f5b0c7d4f6c23fe83bf292425fbe8d0c9c908a"},
+ {file = "rignore-0.6.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:64d379193f86a21fc93762783f36651927f54d5eea54c4922fdccb5e37076ed2"},
+ {file = "rignore-0.6.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53c4f8682cf645b7a9160e0f1786af3201ed54a020bb4abd515c970043387127"},
+ {file = "rignore-0.6.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1246e672bd835a17d3ae91579b3c235ec55b10924ef22608d3e9ec90fa2699"},
+ {file = "rignore-0.6.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82eed48fbc3097af418862e3c5c26fa81aa993e0d8b5f3a0a9a29cc6975eedff"},
+ {file = "rignore-0.6.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:df1215a071d42fd857fb6363c13803fbd915d48eaeaa9b103fb2266ba89c8995"},
+ {file = "rignore-0.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:82f2d318e66756066ed664015d8ca720078ab1d319377f1f61e3f4d01325faea"},
+ {file = "rignore-0.6.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e7d4258fc81051097c4d4c6ad17f0100c40088dbd2c6c31fc3c888a1d5a16190"},
+ {file = "rignore-0.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a0d0b9ec7929df8fd35ae89cb56619850dc140869139d61a2f4fa2941d2d1878"},
+ {file = "rignore-0.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8883d079b948ffcd56b67572831c9b8949eca7fe2e8f7bdbf7691c7a9388f054"},
+ {file = "rignore-0.6.4-cp310-cp310-win32.whl", hash = "sha256:5aeac5b354e15eb9f7857b02ad2af12ae2c2ed25a61921b0bd7e272774530f77"},
+ {file = "rignore-0.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:90419f881d05a1febb0578a175aa3e51d149ded1875421ed75a8af4392b7fe56"},
+ {file = "rignore-0.6.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:85f684dfc2c497e35ad34ffd6744a3bcdcac273ec1dbe7d0464bfa20f3331434"},
+ {file = "rignore-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23954acc6debc852dbccbffbb70f0e26b12d230239e1ad0638eb5540694d0308"},
+ {file = "rignore-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2bf793bd58dbf3dee063a758b23ea446b5f037370405ecefc78e1e8923fc658"},
+ {file = "rignore-0.6.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1eaeaa5a904e098604ea2012383a721de06211c8b4013abf0d41c3cfeb982f4f"},
+ {file = "rignore-0.6.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a48bdbeb03093e3fac2b40d62a718c59b5bb4f29cfdc8e7cbb360e1ea7bf0056"},
+ {file = "rignore-0.6.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c5f9452d116be405f0967160b449c46ac929b50eaf527f33ee4680e3716e39"},
+ {file = "rignore-0.6.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cf1039bfbdaa0f9710a6fb75436c25ca26d364881ec4d1e66d466bb36a7fb98"},
+ {file = "rignore-0.6.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:136629eb0ec2b6ac6ab34e71ce8065a07106fe615a53eceefc30200d528a4612"},
+ {file = "rignore-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:35e3d0ebaf01086e6454c3fecae141e2db74a5ddf4a97c72c69428baeff0b7d4"},
+ {file = "rignore-0.6.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ed1f9010fa1ef5ea0b69803d1dfb4b7355921779e03a30396034c52691658bc"},
+ {file = "rignore-0.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c16e9e898ed0afe2e20fa8d6412e02bd13f039f7e0d964a289368efd4d9ad320"},
+ {file = "rignore-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7e6bc0bdcd404a7a8268629e8e99967127bb41e02d9eb09a471364c4bc25e215"},
+ {file = "rignore-0.6.4-cp311-cp311-win32.whl", hash = "sha256:fdd59bd63d2a49cc6d4f3598f285552ccb1a41e001df1012e0e0345cf2cabf79"},
+ {file = "rignore-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:7bf5be0e8a01845e57b5faa47ef9c623bb2070aa2f743c2fc73321ffaae45701"},
+ {file = "rignore-0.6.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:74720d074b79f32449d5d212ce732e0144a294a184246d1f1e7bcc1fc5c83b69"},
+ {file = "rignore-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a8184fcf567bd6b6d7b85a0c138d98dd40f63054141c96b175844414c5530d7"},
+ {file = "rignore-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcb0d7d7ecc3fbccf6477bb187c04a091579ea139f15f139abe0b3b48bdfef69"},
+ {file = "rignore-0.6.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feac73377a156fb77b3df626c76f7e5893d9b4e9e886ac8c0f9d44f1206a2a91"},
+ {file = "rignore-0.6.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:465179bc30beb1f7a3439e428739a2b5777ed26660712b8c4e351b15a7c04483"},
+ {file = "rignore-0.6.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4a4877b4dca9cf31a4d09845b300c677c86267657540d0b4d3e6d0ce3110e6e9"},
+ {file = "rignore-0.6.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456456802b1e77d1e2d149320ee32505b8183e309e228129950b807d204ddd17"},
+ {file = "rignore-0.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c1ff2fc223f1d9473d36923160af37bf765548578eb9d47a2f52e90da8ae408"},
+ {file = "rignore-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e445fbc214ae18e0e644a78086ea5d0f579e210229a4fbe86367d11a4cd03c11"},
+ {file = "rignore-0.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e07d9c5270fc869bc431aadcfb6ed0447f89b8aafaa666914c077435dc76a123"},
+ {file = "rignore-0.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7a6ccc0ea83d2c0c6df6b166f2acacedcc220a516436490f41e99a5ae73b6019"},
+ {file = "rignore-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:536392c5ec91755db48389546c833c4ab1426fe03e5a8522992b54ef8a244e7e"},
+ {file = "rignore-0.6.4-cp312-cp312-win32.whl", hash = "sha256:f5f9dca46fc41c0a1e236767f68be9d63bdd2726db13a0ae3a30f68414472969"},
+ {file = "rignore-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:e02eecb9e1b9f9bf7c9030ae73308a777bed3b2486204cc74dfcfbe699ab1497"},
+ {file = "rignore-0.6.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2b3b1e266ce45189240d14dfa1057f8013ea34b9bc8b3b44125ec8d25fdb3985"},
+ {file = "rignore-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45fe803628cc14714df10e8d6cdc23950a47eb9eb37dfea9a4779f4c672d2aa0"},
+ {file = "rignore-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e439f034277a947a4126e2da79dbb43e33d73d7c09d3d72a927e02f8a16f59aa"},
+ {file = "rignore-0.6.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84b5121650ae24621154c7bdba8b8970b0739d8146505c9f38e0cda9385d1004"},
+ {file = "rignore-0.6.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52b0957b585ab48a445cf8ac1dbc33a272ab060835e583b4f95aa8c67c23fb2b"},
+ {file = "rignore-0.6.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:50359e0d5287b5e2743bd2f2fbf05df619c8282fd3af12f6628ff97b9675551d"},
+ {file = "rignore-0.6.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe18096dcb1596757dfe0b412aab6d32564473ae7ee58dea0a8b4be5b1a2e3b"},
+ {file = "rignore-0.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b79c212d9990a273ad91e8d9765e1766ef6ecedd3be65375d786a252762ba385"},
+ {file = "rignore-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c6ffa7f2a8894c65aa5dc4e8ac8bbdf39a326c0c6589efd27686cfbb48f0197d"},
+ {file = "rignore-0.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a63f5720dffc8d8fb0a4d02fafb8370a4031ebf3f99a4e79f334a91e905b7349"},
+ {file = "rignore-0.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ce33982da47ac5dc09d19b04fa8d7c9aa6292fc0bd1ecf33076989faa8886094"},
+ {file = "rignore-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d899621867aa266824fbd9150e298f19d25b93903ef0133c09f70c65a3416eca"},
+ {file = "rignore-0.6.4-cp313-cp313-win32.whl", hash = "sha256:d0615a6bf4890ec5a90b5fb83666822088fbd4e8fcd740c386fcce51e2f6feea"},
+ {file = "rignore-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:145177f0e32716dc2f220b07b3cde2385b994b7ea28d5c96fbec32639e9eac6f"},
+ {file = "rignore-0.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e55bf8f9bbd186f58ab646b4a08718c77131d28a9004e477612b0cbbd5202db2"},
+ {file = "rignore-0.6.4-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2521f7bf3ee1f2ab22a100a3a4eed39a97b025804e5afe4323528e9ce8f084a5"},
+ {file = "rignore-0.6.4-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cc35773a8a9c119359ef974d0856988d4601d4daa6f532c05f66b4587cf35bc"},
+ {file = "rignore-0.6.4-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b665b1ea14457d7b49e834baabc635a3b8c10cfb5cca5c21161fabdbfc2b850e"},
+ {file = "rignore-0.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c7fd339f344a8548724f289495b835bed7b81174a0bc1c28c6497854bd8855db"},
+ {file = "rignore-0.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:91dc94b1cc5af8d6d25ce6edd29e7351830f19b0a03b75cb3adf1f76d00f3007"},
+ {file = "rignore-0.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4d1918221a249e5342b60fd5fa513bf3d6bf272a8738e66023799f0c82ecd788"},
+ {file = "rignore-0.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:240777332b859dc89dcba59ab6e3f1e062bc8e862ffa3e5f456e93f7fd5cb415"},
+ {file = "rignore-0.6.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66b0e548753e55cc648f1e7b02d9f74285fe48bb49cec93643d31e563773ab3f"},
+ {file = "rignore-0.6.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6971ac9fdd5a0bd299a181096f091c4f3fd286643adceba98eccc03c688a6637"},
+ {file = "rignore-0.6.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:7fa603aeaf9c1c536fb55527e24816d797a607434e2a8d45acf1683efc566fb6"},
+ {file = "rignore-0.6.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1721763b59437a7800be6641b83bbf6d5d3dd029363d7518a78b0486e3a988a1"},
+ {file = "rignore-0.6.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74d66d3172f7e67f3c0bb3cfa4d7a7c234cc71480bd8878bdfbf55a9972e9956"},
+ {file = "rignore-0.6.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:867460ecf9d09bd2a43f4ba5ce97df1920d460527e623e8a2e4d27f64ca5909a"},
+ {file = "rignore-0.6.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c0b8cb660cb991a307bac05436d5c91829098c73bf0f4ffb3a3e035abf9fb34"},
+ {file = "rignore-0.6.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:857e37f42cb1c70eaac56d79d46d987cc4cb6926c5a577d34d1b948a9bb9d63e"},
+ {file = "rignore-0.6.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fc23741267fea2df981e995c49ecca9f888d05e1436ef9b4fc4681f09dadc27"},
+ {file = "rignore-0.6.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8efac1560a12e214771afcb70c7a8bf03966dfa8b3cda216a150b009b02da2dc"},
+ {file = "rignore-0.6.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:1f05d1a8660f668b3b8b8b02edbdccd2a94b36680e7d7b28effb2374459c730a"},
+ {file = "rignore-0.6.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:714aa11043ee6b2cf307b9be7319f05587a3a31bf9d3f24203e38fa43d6fd8e4"},
+ {file = "rignore-0.6.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:1921d1fca65f1ae4b1e376239eba6c0c029361fea7f4aeadf3b372506fe2153f"},
+ {file = "rignore-0.6.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:1b8493990fb15811525d81e7969e521a3071ac7ee998860b46e6ccbcce6eca4b"},
+ {file = "rignore-0.6.4-cp38-cp38-win32.whl", hash = "sha256:abadfd4dac2564f47ed26ae1b56b199dc5679d987e6c720868e26c53dae8dafa"},
+ {file = "rignore-0.6.4-cp38-cp38-win_amd64.whl", hash = "sha256:b149dbba5c786f29cba3ba3b43af8771a1969fa7ccde9895487260fb87e43a4e"},
+ {file = "rignore-0.6.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:6a336ec8b8d4da5063b09a95042740491ca822afb4fcce2530bb6e5e3baf0845"},
+ {file = "rignore-0.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1ed8a704960fa3764b510a88ececef0c8d0447d4dfc64ab0e2e28321952cfed8"},
+ {file = "rignore-0.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6e758c42ec19b3545e66f73dc7cf584f3ed7ea5029dd6716a3257f68db6c0c8"},
+ {file = "rignore-0.6.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d8aca66ca2d08e0ae684dde745daecdfe8997a5c873c51b22771ecf3afb17d33"},
+ {file = "rignore-0.6.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b14a8bc6929914c13d346607a076d2762e3ac26eb179866277c5ee06c9669f"},
+ {file = "rignore-0.6.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e903431cb6ac2a0416c5ffab16b103a1d85f2c337371f107a6838ece5f2052e"},
+ {file = "rignore-0.6.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa969d5d9148fcde194d9ddb547df2e7b6d321ad68d1dc906934e2c0fc3b939"},
+ {file = "rignore-0.6.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e9f3602d3c162c3061a004648e5f1bf81a3ed54d2c3052318008a7f7cee6fb46"},
+ {file = "rignore-0.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8544f45f583a5ab7cd1ad38564c473b2aac136a3a770abbcb92fca6d37eaa84e"},
+ {file = "rignore-0.6.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:0e1c95f7bd8a32fd483a86c301e88853f433423d7be58b173ee55d8612354ff0"},
+ {file = "rignore-0.6.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e1af717d4f01a84ecb6976badb20f37595bc31158fc60dae35f7f4cda16eef41"},
+ {file = "rignore-0.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7b73b1626938a17b6766b092b71abc05fcdb1bf9ed9e4ee0b86362e8f81105f1"},
+ {file = "rignore-0.6.4-cp39-cp39-win32.whl", hash = "sha256:f9a7219da435de314a822988503f78fa4917e668e42985c6e427f2c66580a652"},
+ {file = "rignore-0.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:b56e2ed34103afb39f767aa3787138738fb4b0a82d748269f27cc81d59940bc7"},
+ {file = "rignore-0.6.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40f493eef4b191777ba6d16879e3f73836142e04480d2e2f483675d652e6b559"},
+ {file = "rignore-0.6.4-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6790635e4df35333e27cd9e8b31d1d559826cf8b52f2c374b81ab698ac0140cf"},
+ {file = "rignore-0.6.4-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e326dab28787f07c6987c04686d4ad9d4b1e1caca1a15b85d443f91af2e133d2"},
+ {file = "rignore-0.6.4-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd24cb0f58c6036b0f64ac6fc3f759b7f0de5506fa9f5a65e9d57f8cf44a026d"},
+ {file = "rignore-0.6.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36cb95b0acae3c88b99a39f4246b395fd983848f3ec85ff26531d638b6584a45"},
+ {file = "rignore-0.6.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dfc954973429ce545d06163d87a6bae0ccea5703adbc957ee3d332c9592a58eb"},
+ {file = "rignore-0.6.4-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:cbed37d7c128b58ab9ade80e131efc4a48b6d045cd0bd1d3254cbb6b4a0ad67e"},
+ {file = "rignore-0.6.4-pp310-pypy310_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:a0db910ef867d6ca2d52fefd22d8b6b63b20ec61661e2ad57e5c425a4e39431a"},
+ {file = "rignore-0.6.4-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:d664443a0a71d0a7d669adf32be59c4249bbff8b2810960f1b91d413ee4cf6b8"},
+ {file = "rignore-0.6.4-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:b9f6f1d91429b4a6772152848815cf1459663796b7b899a0e15d9198e32c9371"},
+ {file = "rignore-0.6.4-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b3da26d5a35ab15525b68d30b7352ad2247321f5201fc7e50ba6d547f78d5ea"},
+ {file = "rignore-0.6.4-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43028f3587558231d9fa68accff58c901dc50fd7bbc5764d3ee3df95290f6ebf"},
+ {file = "rignore-0.6.4-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc56f1fcab7740751b98fead67b98ba64896424d8c834ea22089568db4e36dfa"},
+ {file = "rignore-0.6.4-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6033f2280898535a5f69935e08830a4e49ff1e29ef2c3f9a2b9ced59de06fdbf"},
+ {file = "rignore-0.6.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f5ac0c4e6a24be88f3821e101ef4665e9e1dc015f9e45109f32fed71dbcdafa"},
+ {file = "rignore-0.6.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8906ac8dd585ece83b1346e0470260a1951058cc0ef5a17542069bde4aa3f42f"},
+ {file = "rignore-0.6.4-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:14d095622969504a2e56f666286202dad583f08d3347b7be2d647ddfd7a9bf47"},
+ {file = "rignore-0.6.4-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:30f3d688df7eb4850318f1b5864d14f2c5fe5dbf3803ed0fc8329d2a7ad560dc"},
+ {file = "rignore-0.6.4-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:028f62a7b0a6235bb3f03c9e7f342352e7fa4b3f08c761c72f9de8faee40ed9c"},
+ {file = "rignore-0.6.4-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:7e6c425603db2c147eace4f752ca3cd4551e7568c9d332175d586c68bcbe3d8d"},
+ {file = "rignore-0.6.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:518662bee0d4c6e2b220fef28e4851a149921ff0eac1a060f739c5490b72754e"},
+ {file = "rignore-0.6.4-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:285b60526360aab1bf3cacdf6efe22b90f3b80392dcb4f8ae1e3257043c4c5ad"},
+ {file = "rignore-0.6.4-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72ec7c6fb883775a70bfc1ae6b8d2d709d76da16e8a23eeda93ca86a1a6b81e4"},
+ {file = "rignore-0.6.4-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54662abe43fa4dfc905bcb5af29163fe40dfc5ccae83fccbe6abd5a73b23d776"},
+ {file = "rignore-0.6.4-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:5219f65b5df40d86e11fc91a0d25ced2b324a7569ac730c75775198e9ef3f5b5"},
+ {file = "rignore-0.6.4-pp39-pypy39_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:4be42621233bf03dd6acfe17cf7ad2a29bc84ac008564feea3ec412cabac5b5a"},
+ {file = "rignore-0.6.4-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:dd3db7ce8350a30507239a7d3239dc697c1a958c1f09e230529612de2882f2ff"},
+ {file = "rignore-0.6.4-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:3ac29f2366b3b0135de7c3a0ed6a9a62f793d8bb518e53479d23f1870fdc0fe9"},
+ {file = "rignore-0.6.4.tar.gz", hash = "sha256:e893fdd2d7fdcfa9407d0b7600ef2c2e2df97f55e1c45d4a8f54364829ddb0ab"},
+]
+
+[[package]]
+name = "rsa"
+version = "4.2"
+description = "Pure-Python RSA implementation"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "rsa-4.2.tar.gz", hash = "sha256:aaefa4b84752e3e99bd8333a2e1e3e7a7da64614042bd66f775573424370108a"},
+]
+
+[package.dependencies]
+pyasn1 = ">=0.1.3"
+
+[[package]]
+name = "ruff"
+version = "0.12.12"
+description = "An extremely fast Python linter and code formatter, written in Rust."
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "ruff-0.12.12-py3-none-linux_armv6l.whl", hash = "sha256:de1c4b916d98ab289818e55ce481e2cacfaad7710b01d1f990c497edf217dafc"},
+ {file = "ruff-0.12.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7acd6045e87fac75a0b0cdedacf9ab3e1ad9d929d149785903cff9bb69ad9727"},
+ {file = "ruff-0.12.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:abf4073688d7d6da16611f2f126be86523a8ec4343d15d276c614bda8ec44edb"},
+ {file = "ruff-0.12.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:968e77094b1d7a576992ac078557d1439df678a34c6fe02fd979f973af167577"},
+ {file = "ruff-0.12.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42a67d16e5b1ffc6d21c5f67851e0e769517fb57a8ebad1d0781b30888aa704e"},
+ {file = "ruff-0.12.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b216ec0a0674e4b1214dcc998a5088e54eaf39417327b19ffefba1c4a1e4971e"},
+ {file = "ruff-0.12.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:59f909c0fdd8f1dcdbfed0b9569b8bf428cf144bec87d9de298dcd4723f5bee8"},
+ {file = "ruff-0.12.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ac93d87047e765336f0c18eacad51dad0c1c33c9df7484c40f98e1d773876f5"},
+ {file = "ruff-0.12.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01543c137fd3650d322922e8b14cc133b8ea734617c4891c5a9fccf4bfc9aa92"},
+ {file = "ruff-0.12.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afc2fa864197634e549d87fb1e7b6feb01df0a80fd510d6489e1ce8c0b1cc45"},
+ {file = "ruff-0.12.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0c0945246f5ad776cb8925e36af2438e66188d2b57d9cf2eed2c382c58b371e5"},
+ {file = "ruff-0.12.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a0fbafe8c58e37aae28b84a80ba1817f2ea552e9450156018a478bf1fa80f4e4"},
+ {file = "ruff-0.12.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b9c456fb2fc8e1282affa932c9e40f5ec31ec9cbb66751a316bd131273b57c23"},
+ {file = "ruff-0.12.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5f12856123b0ad0147d90b3961f5c90e7427f9acd4b40050705499c98983f489"},
+ {file = "ruff-0.12.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:26a1b5a2bf7dd2c47e3b46d077cd9c0fc3b93e6c6cc9ed750bd312ae9dc302ee"},
+ {file = "ruff-0.12.12-py3-none-win32.whl", hash = "sha256:173be2bfc142af07a01e3a759aba6f7791aa47acf3604f610b1c36db888df7b1"},
+ {file = "ruff-0.12.12-py3-none-win_amd64.whl", hash = "sha256:e99620bf01884e5f38611934c09dd194eb665b0109104acae3ba6102b600fd0d"},
+ {file = "ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093"},
+ {file = "ruff-0.12.12.tar.gz", hash = "sha256:b86cd3415dbe31b3b46a71c598f4c4b2f550346d1ccf6326b347cc0c8fd063d6"},
+]
+
+[[package]]
+name = "sentry-sdk"
+version = "2.36.0"
+description = "Python client for Sentry (https://sentry.io)"
+optional = false
+python-versions = ">=3.6"
+groups = ["main"]
+files = [
+ {file = "sentry_sdk-2.36.0-py2.py3-none-any.whl", hash = "sha256:0f95586a141068d215376e5bf8ebd279e126f7f42805e9570190ef82a7e232b3"},
+ {file = "sentry_sdk-2.36.0.tar.gz", hash = "sha256:af9260e8155e41e8217615a453828e98aa40740865ac4b16b1ccb6a63b4b2e31"},
+]
+
+[package.dependencies]
+certifi = "*"
+urllib3 = ">=1.26.11"
+
+[package.extras]
+aiohttp = ["aiohttp (>=3.5)"]
+anthropic = ["anthropic (>=0.16)"]
+arq = ["arq (>=0.23)"]
+asyncpg = ["asyncpg (>=0.23)"]
+beam = ["apache-beam (>=2.12)"]
+bottle = ["bottle (>=0.12.13)"]
+celery = ["celery (>=3)"]
+celery-redbeat = ["celery-redbeat (>=2)"]
+chalice = ["chalice (>=1.16.0)"]
+clickhouse-driver = ["clickhouse-driver (>=0.2.0)"]
+django = ["django (>=1.8)"]
+falcon = ["falcon (>=1.4)"]
+fastapi = ["fastapi (>=0.79.0)"]
+flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"]
+grpcio = ["grpcio (>=1.21.1)", "protobuf (>=3.8.0)"]
+http2 = ["httpcore[http2] (==1.*)"]
+httpx = ["httpx (>=0.16.0)"]
+huey = ["huey (>=2)"]
+huggingface-hub = ["huggingface_hub (>=0.22)"]
+langchain = ["langchain (>=0.0.210)"]
+launchdarkly = ["launchdarkly-server-sdk (>=9.8.0)"]
+litestar = ["litestar (>=2.0.0)"]
+loguru = ["loguru (>=0.5)"]
+openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"]
+openfeature = ["openfeature-sdk (>=0.7.1)"]
+opentelemetry = ["opentelemetry-distro (>=0.35b0)"]
+opentelemetry-experimental = ["opentelemetry-distro"]
+pure-eval = ["asttokens", "executing", "pure_eval"]
+pymongo = ["pymongo (>=3.1)"]
+pyspark = ["pyspark (>=2.4.4)"]
+quart = ["blinker (>=1.1)", "quart (>=0.16.1)"]
+rq = ["rq (>=0.6)"]
+sanic = ["sanic (>=0.8)"]
+sqlalchemy = ["sqlalchemy (>=1.2)"]
+starlette = ["starlette (>=0.19.1)"]
+starlite = ["starlite (>=1.48)"]
+statsig = ["statsig (>=0.55.3)"]
+tornado = ["tornado (>=6)"]
+unleash = ["UnleashClient (>=6.0.1)"]
+
+[[package]]
+name = "shellingham"
+version = "1.5.4"
+description = "Tool to Detect Surrounding Shell"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"},
+ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"},
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+description = "Sniff out which async library your code is running under"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
+ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
+]
+
+[[package]]
+name = "starlette"
+version = "0.47.3"
+description = "The little ASGI library that shines."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51"},
+ {file = "starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9"},
+]
+
+[package.dependencies]
+anyio = ">=3.6.2,<5"
+typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""}
+
+[package.extras]
+full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
+
+[[package]]
+name = "tenacity"
+version = "9.1.2"
+description = "Retry code until it succeeds"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"},
+ {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"},
+]
+
+[package.extras]
+doc = ["reno", "sphinx"]
+test = ["pytest", "tornado (>=4.5)", "typeguard"]
+
+[[package]]
+name = "typer"
+version = "0.17.3"
+description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "typer-0.17.3-py3-none-any.whl", hash = "sha256:643919a79182ab7ac7581056d93c6a2b865b026adf2872c4d02c72758e6f095b"},
+ {file = "typer-0.17.3.tar.gz", hash = "sha256:0c600503d472bcf98d29914d4dcd67f80c24cc245395e2e00ba3603c9332e8ba"},
+]
+
+[package.dependencies]
+click = ">=8.0.0"
+rich = ">=10.11.0"
+shellingham = ">=1.3.0"
+typing-extensions = ">=3.7.4.3"
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+description = "Backported and Experimental Type Hints for Python 3.9+"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
+ {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.1"
+description = "Runtime typing introspection tools"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"},
+ {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"},
+]
+
+[package.dependencies]
+typing-extensions = ">=4.12.0"
+
+[[package]]
+name = "urllib3"
+version = "2.5.0"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
+ {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
+]
+
+[package.extras]
+brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""]
+h2 = ["h2 (>=4,<5)"]
+socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
+zstd = ["zstandard (>=0.18.0)"]
+
+[[package]]
+name = "uvicorn"
+version = "0.35.0"
+description = "The lightning-fast ASGI server."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"},
+ {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"},
+]
+
+[package.dependencies]
+click = ">=7.0"
+colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""}
+h11 = ">=0.8"
+httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""}
+python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
+pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""}
+uvloop = {version = ">=0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
+watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
+websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
+
+[package.extras]
+standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"]
+
+[[package]]
+name = "uvloop"
+version = "0.21.0"
+description = "Fast implementation of asyncio event loop on top of libuv"
+optional = false
+python-versions = ">=3.8.0"
+groups = ["main"]
+markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\""
+files = [
+ {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f"},
+ {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d"},
+ {file = "uvloop-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f38b2e090258d051d68a5b14d1da7203a3c3677321cf32a95a6f4db4dd8b6f26"},
+ {file = "uvloop-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c43e0f13022b998eb9b973b5e97200c8b90823454d4bc06ab33829e09fb9bb"},
+ {file = "uvloop-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10d66943def5fcb6e7b37310eb6b5639fd2ccbc38df1177262b0640c3ca68c1f"},
+ {file = "uvloop-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67dd654b8ca23aed0a8e99010b4c34aca62f4b7fce88f39d452ed7622c94845c"},
+ {file = "uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8"},
+ {file = "uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0"},
+ {file = "uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e"},
+ {file = "uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb"},
+ {file = "uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6"},
+ {file = "uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d"},
+ {file = "uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c"},
+ {file = "uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2"},
+ {file = "uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d"},
+ {file = "uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc"},
+ {file = "uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb"},
+ {file = "uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f"},
+ {file = "uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281"},
+ {file = "uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af"},
+ {file = "uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6"},
+ {file = "uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816"},
+ {file = "uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc"},
+ {file = "uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553"},
+ {file = "uvloop-0.21.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:17df489689befc72c39a08359efac29bbee8eee5209650d4b9f34df73d22e414"},
+ {file = "uvloop-0.21.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bc09f0ff191e61c2d592a752423c767b4ebb2986daa9ed62908e2b1b9a9ae206"},
+ {file = "uvloop-0.21.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0ce1b49560b1d2d8a2977e3ba4afb2414fb46b86a1b64056bc4ab929efdafbe"},
+ {file = "uvloop-0.21.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e678ad6fe52af2c58d2ae3c73dc85524ba8abe637f134bf3564ed07f555c5e79"},
+ {file = "uvloop-0.21.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:460def4412e473896ef179a1671b40c039c7012184b627898eea5072ef6f017a"},
+ {file = "uvloop-0.21.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:10da8046cc4a8f12c91a1c39d1dd1585c41162a15caaef165c2174db9ef18bdc"},
+ {file = "uvloop-0.21.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c097078b8031190c934ed0ebfee8cc5f9ba9642e6eb88322b9958b649750f72b"},
+ {file = "uvloop-0.21.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:46923b0b5ee7fc0020bef24afe7836cb068f5050ca04caf6b487c513dc1a20b2"},
+ {file = "uvloop-0.21.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53e420a3afe22cdcf2a0f4846e377d16e718bc70103d7088a4f7623567ba5fb0"},
+ {file = "uvloop-0.21.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88cb67cdbc0e483da00af0b2c3cdad4b7c61ceb1ee0f33fe00e09c81e3a6cb75"},
+ {file = "uvloop-0.21.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:221f4f2a1f46032b403bf3be628011caf75428ee3cc204a22addf96f586b19fd"},
+ {file = "uvloop-0.21.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2d1f581393673ce119355d56da84fe1dd9d2bb8b3d13ce792524e1607139feff"},
+ {file = "uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3"},
+]
+
+[package.extras]
+dev = ["Cython (>=3.0,<4.0)", "setuptools (>=60)"]
+docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"]
+test = ["aiohttp (>=3.10.5)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"]
+
+[[package]]
+name = "watchfiles"
+version = "1.1.0"
+description = "Simple, modern and high performance file watching and code reload in python."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "watchfiles-1.1.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:27f30e14aa1c1e91cb653f03a63445739919aef84c8d2517997a83155e7a2fcc"},
+ {file = "watchfiles-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3366f56c272232860ab45c77c3ca7b74ee819c8e1f6f35a7125556b198bbc6df"},
+ {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8412eacef34cae2836d891836a7fff7b754d6bcac61f6c12ba5ca9bc7e427b68"},
+ {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df670918eb7dd719642e05979fc84704af913d563fd17ed636f7c4783003fdcc"},
+ {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7642b9bc4827b5518ebdb3b82698ada8c14c7661ddec5fe719f3e56ccd13c97"},
+ {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:199207b2d3eeaeb80ef4411875a6243d9ad8bc35b07fc42daa6b801cc39cc41c"},
+ {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a479466da6db5c1e8754caee6c262cd373e6e6c363172d74394f4bff3d84d7b5"},
+ {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:935f9edd022ec13e447e5723a7d14456c8af254544cefbc533f6dd276c9aa0d9"},
+ {file = "watchfiles-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8076a5769d6bdf5f673a19d51da05fc79e2bbf25e9fe755c47595785c06a8c72"},
+ {file = "watchfiles-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:86b1e28d4c37e89220e924305cd9f82866bb0ace666943a6e4196c5df4d58dcc"},
+ {file = "watchfiles-1.1.0-cp310-cp310-win32.whl", hash = "sha256:d1caf40c1c657b27858f9774d5c0e232089bca9cb8ee17ce7478c6e9264d2587"},
+ {file = "watchfiles-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:a89c75a5b9bc329131115a409d0acc16e8da8dfd5867ba59f1dd66ae7ea8fa82"},
+ {file = "watchfiles-1.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c9649dfc57cc1f9835551deb17689e8d44666315f2e82d337b9f07bd76ae3aa2"},
+ {file = "watchfiles-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:406520216186b99374cdb58bc48e34bb74535adec160c8459894884c983a149c"},
+ {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb45350fd1dc75cd68d3d72c47f5b513cb0578da716df5fba02fff31c69d5f2d"},
+ {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11ee4444250fcbeb47459a877e5e80ed994ce8e8d20283857fc128be1715dac7"},
+ {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bda8136e6a80bdea23e5e74e09df0362744d24ffb8cd59c4a95a6ce3d142f79c"},
+ {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b915daeb2d8c1f5cee4b970f2e2c988ce6514aace3c9296e58dd64dc9aa5d575"},
+ {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed8fc66786de8d0376f9f913c09e963c66e90ced9aa11997f93bdb30f7c872a8"},
+ {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe4371595edf78c41ef8ac8df20df3943e13defd0efcb732b2e393b5a8a7a71f"},
+ {file = "watchfiles-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b7c5f6fe273291f4d414d55b2c80d33c457b8a42677ad14b4b47ff025d0893e4"},
+ {file = "watchfiles-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7738027989881e70e3723c75921f1efa45225084228788fc59ea8c6d732eb30d"},
+ {file = "watchfiles-1.1.0-cp311-cp311-win32.whl", hash = "sha256:622d6b2c06be19f6e89b1d951485a232e3b59618def88dbeda575ed8f0d8dbf2"},
+ {file = "watchfiles-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:48aa25e5992b61debc908a61ab4d3f216b64f44fdaa71eb082d8b2de846b7d12"},
+ {file = "watchfiles-1.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:00645eb79a3faa70d9cb15c8d4187bb72970b2470e938670240c7998dad9f13a"},
+ {file = "watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179"},
+ {file = "watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5"},
+ {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297"},
+ {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0"},
+ {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e"},
+ {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee"},
+ {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd"},
+ {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f"},
+ {file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4"},
+ {file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f"},
+ {file = "watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd"},
+ {file = "watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47"},
+ {file = "watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6"},
+ {file = "watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30"},
+ {file = "watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a"},
+ {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc"},
+ {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b"},
+ {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895"},
+ {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a"},
+ {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b"},
+ {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c"},
+ {file = "watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b"},
+ {file = "watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb"},
+ {file = "watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9"},
+ {file = "watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7"},
+ {file = "watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5"},
+ {file = "watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1"},
+ {file = "watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339"},
+ {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633"},
+ {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011"},
+ {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670"},
+ {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf"},
+ {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4"},
+ {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20"},
+ {file = "watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef"},
+ {file = "watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb"},
+ {file = "watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297"},
+ {file = "watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018"},
+ {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0"},
+ {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12"},
+ {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb"},
+ {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77"},
+ {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92"},
+ {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e"},
+ {file = "watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b"},
+ {file = "watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259"},
+ {file = "watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f"},
+ {file = "watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e"},
+ {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa"},
+ {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8"},
+ {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f"},
+ {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e"},
+ {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb"},
+ {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147"},
+ {file = "watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8"},
+ {file = "watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db"},
+ {file = "watchfiles-1.1.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:865c8e95713744cf5ae261f3067861e9da5f1370ba91fc536431e29b418676fa"},
+ {file = "watchfiles-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42f92befc848bb7a19658f21f3e7bae80d7d005d13891c62c2cd4d4d0abb3433"},
+ {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa0cc8365ab29487eb4f9979fd41b22549853389e22d5de3f134a6796e1b05a4"},
+ {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90ebb429e933645f3da534c89b29b665e285048973b4d2b6946526888c3eb2c7"},
+ {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c588c45da9b08ab3da81d08d7987dae6d2a3badd63acdb3e206a42dbfa7cb76f"},
+ {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c55b0f9f68590115c25272b06e63f0824f03d4fc7d6deed43d8ad5660cabdbf"},
+ {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd17a1e489f02ce9117b0de3c0b1fab1c3e2eedc82311b299ee6b6faf6c23a29"},
+ {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da71945c9ace018d8634822f16cbc2a78323ef6c876b1d34bbf5d5222fd6a72e"},
+ {file = "watchfiles-1.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:51556d5004887045dba3acdd1fdf61dddea2be0a7e18048b5e853dcd37149b86"},
+ {file = "watchfiles-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04e4ed5d1cd3eae68c89bcc1a485a109f39f2fd8de05f705e98af6b5f1861f1f"},
+ {file = "watchfiles-1.1.0-cp39-cp39-win32.whl", hash = "sha256:c600e85f2ffd9f1035222b1a312aff85fd11ea39baff1d705b9b047aad2ce267"},
+ {file = "watchfiles-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:3aba215958d88182e8d2acba0fdaf687745180974946609119953c0e112397dc"},
+ {file = "watchfiles-1.1.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a6fd40bbb50d24976eb275ccb55cd1951dfb63dbc27cae3066a6ca5f4beabd5"},
+ {file = "watchfiles-1.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9f811079d2f9795b5d48b55a37aa7773680a5659afe34b54cc1d86590a51507d"},
+ {file = "watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2726d7bfd9f76158c84c10a409b77a320426540df8c35be172444394b17f7ea"},
+ {file = "watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df32d59cb9780f66d165a9a7a26f19df2c7d24e3bd58713108b41d0ff4f929c6"},
+ {file = "watchfiles-1.1.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0ece16b563b17ab26eaa2d52230c9a7ae46cf01759621f4fbbca280e438267b3"},
+ {file = "watchfiles-1.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:51b81e55d40c4b4aa8658427a3ee7ea847c591ae9e8b81ef94a90b668999353c"},
+ {file = "watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2bcdc54ea267fe72bfc7d83c041e4eb58d7d8dc6f578dfddb52f037ce62f432"},
+ {file = "watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923fec6e5461c42bd7e3fd5ec37492c6f3468be0499bc0707b4bbbc16ac21792"},
+ {file = "watchfiles-1.1.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7b3443f4ec3ba5aa00b0e9fa90cf31d98321cbff8b925a7c7b84161619870bc9"},
+ {file = "watchfiles-1.1.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7049e52167fc75fc3cc418fc13d39a8e520cbb60ca08b47f6cedb85e181d2f2a"},
+ {file = "watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54062ef956807ba806559b3c3d52105ae1827a0d4ab47b621b31132b6b7e2866"},
+ {file = "watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a7bd57a1bb02f9d5c398c0c1675384e7ab1dd39da0ca50b7f09af45fa435277"},
+ {file = "watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575"},
+]
+
+[package.dependencies]
+anyio = ">=3.0.0"
+
+[[package]]
+name = "websockets"
+version = "15.0.1"
+description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"},
+ {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"},
+ {file = "websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a"},
+ {file = "websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e"},
+ {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf"},
+ {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb"},
+ {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d"},
+ {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9"},
+ {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c"},
+ {file = "websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256"},
+ {file = "websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41"},
+ {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431"},
+ {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57"},
+ {file = "websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905"},
+ {file = "websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562"},
+ {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792"},
+ {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413"},
+ {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8"},
+ {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3"},
+ {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf"},
+ {file = "websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85"},
+ {file = "websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065"},
+ {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3"},
+ {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665"},
+ {file = "websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2"},
+ {file = "websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215"},
+ {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5"},
+ {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65"},
+ {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe"},
+ {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4"},
+ {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597"},
+ {file = "websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9"},
+ {file = "websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7"},
+ {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931"},
+ {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675"},
+ {file = "websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151"},
+ {file = "websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22"},
+ {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f"},
+ {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8"},
+ {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375"},
+ {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d"},
+ {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4"},
+ {file = "websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa"},
+ {file = "websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561"},
+ {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5"},
+ {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a"},
+ {file = "websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b"},
+ {file = "websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770"},
+ {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb"},
+ {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054"},
+ {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee"},
+ {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed"},
+ {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880"},
+ {file = "websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411"},
+ {file = "websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4"},
+ {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3"},
+ {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1"},
+ {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475"},
+ {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9"},
+ {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04"},
+ {file = "websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122"},
+ {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940"},
+ {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e"},
+ {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9"},
+ {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b"},
+ {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f"},
+ {file = "websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123"},
+ {file = "websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f"},
+ {file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"},
+]
+
+[extras]
+dev = ["mypy", "ruff"]
+
+[metadata]
+lock-version = "2.1"
+python-versions = ">=3.12"
+content-hash = "9a4fec08692c894d85730162f65faa21d892535e244a320bf3a812e9e458a057"
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index ce702aa..80b0820 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -14,6 +14,7 @@ dependencies = [
"mypy>=1.16.1",
"python-multipart>=0.0.22",
"ruff>=0.12.1",
+ "parsedatetime (>=2.6,<3.0)",
]
[project.optional-dependencies]
diff --git a/backend/schema.py b/backend/schema.py
index 185d88e..2187bf9 100644
--- a/backend/schema.py
+++ b/backend/schema.py
@@ -1,4 +1,4 @@
-from typing import Literal
+from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -20,11 +20,11 @@ class TextProperties(BaseSchema):
class BaseScrubber(BaseSchema):
id: str = Field(description="Unique identifier for the scrubber")
mediaType: Literal["video", "image", "audio", "text"] = Field(description="Type of media")
- mediaUrlLocal: str | None = Field(description="Local URL for the media file", default=None)
- mediaUrlRemote: str | None = Field(description="Remote URL for the media file", default=None)
+ mediaUrlLocal: str | None = Field(default=None, description="Local URL for the media file")
+ mediaUrlRemote: str | None = Field(default=None, description="Remote URL for the media file")
media_width: int = Field(description="Width of the media in pixels")
media_height: int = Field(description="Height of the media in pixels")
- text: TextProperties | None = Field(description="Text properties if mediaType is text", default=None)
+ text: TextProperties | None = Field(default=None, description="Text properties if mediaType is text")
class MediaBinItem(BaseScrubber):
@@ -36,7 +36,7 @@ class ScrubberState(MediaBinItem):
left: int = Field(description="Left position in pixels on the timeline")
y: int = Field(description="Track position (0-based index)")
width: int = Field(description="Width of the scrubber in pixels")
-
+
# Player properties
left_player: int = Field(description="Left position in the player view")
top_player: int = Field(description="Top position in the player view")
@@ -54,46 +54,23 @@ class TimelineState(BaseSchema):
tracks: list[TrackState] = Field(description="List of tracks in the timeline")
-class LLMAddScrubberToTimelineArgs(BaseSchema):
- function_name: Literal["LLMAddScrubberToTimeline"] = Field(
- description="The name of the function to call"
- )
- scrubber_id: str = Field(
- description="The id of the scrubber to add to the timeline"
- )
- track_id: str = Field(description="The id of the track to add the scrubber to")
- drop_left_px: int = Field(description="The left position of the scrubber in pixels")
-
-
-class LLMMoveScrubberArgs(BaseSchema):
- function_name: Literal["LLMMoveScrubber"] = Field(
- description="The name of the function to call"
- )
- scrubber_id: str = Field(description="The id of the scrubber to move")
- new_position_seconds: float = Field(
- description="The new position of the scrubber in seconds"
- )
- new_track_number: int = Field(description="The new track number of the scrubber")
- pixels_per_second: int = Field(description="The number of pixels per second")
+class UniversalToolCall(BaseSchema):
+ """V2 universal tool-call envelope.
+ - function_name: name of the tool to execute
+ - arguments: free-form args specific to the tool (extensible without code changes)
+ """
-class LLMAddScrubberByNameArgs(BaseSchema):
- function_name: Literal["LLMAddScrubberByName"] = Field(
- description="The name of the function to call"
- )
- scrubber_name: str = Field(description="The partial or full name of the media to add")
- track_number: int = Field(description="1-based track number to add to")
- position_seconds: float = Field(description="Timeline time in seconds to place the media at")
- pixels_per_second: int = Field(description="Pixels per second to convert time to pixels")
+ function_name: str = Field(description="The name of the function to call")
+ arguments: dict[str, Any] | None = Field(default=None, description="Arguments for the function call")
-class LLMDeleteScrubbersInTrackArgs(BaseSchema):
- function_name: Literal["LLMDeleteScrubbersInTrack"] = Field(
- description="The name of the function to call"
- )
- track_number: int = Field(description="1-based track number whose scrubbers will be removed")
+class FunctionCallResponse(BaseSchema):
+ """V2 AI response shape (universal schema).
+ - function_call: UniversalToolCall when an action is requested
+ - assistant_message: text response when no action is needed
+ """
-class FunctionCallResponse(BaseSchema):
- function_call: LLMAddScrubberToTimelineArgs | LLMMoveScrubberArgs | LLMAddScrubberByNameArgs | LLMDeleteScrubbersInTrackArgs | None = None
+ function_call: UniversalToolCall | None = None
assistant_message: str | None = None
diff --git a/backend/tools_registry.py b/backend/tools_registry.py
new file mode 100644
index 0000000..0f144cd
--- /dev/null
+++ b/backend/tools_registry.py
@@ -0,0 +1,191 @@
+from __future__ import annotations
+
+import json
+from typing import Any, Dict, List
+
+
+def get_tools_catalog() -> List[Dict[str, Any]]:
+ """Return the catalog of available tools in a provider-agnostic format.
+
+ Each tool entry contains:
+ - name: Stable tool name the model should return in function_call.function_name
+ - description: Short human-readable description
+ - arguments: JSON Schema-like dict describing properties, types, and required keys
+ """
+ return [
+ {
+ "name": "AddMediaById",
+ "description": "Add an asset (by its media bin id) to a track at a start time, optionally with a duration or end time.",
+ "arguments": {
+ "type": "object",
+ "properties": {
+ "scrubber_id": {"type": "string"},
+ "track_number": {"type": "integer", "minimum": 1},
+ "start_seconds": {"type": "number", "minimum": 0},
+ "duration_seconds": {"type": "number", "minimum": 0},
+ "end_seconds": {"type": "number", "minimum": 0},
+ "pixels_per_second": {"type": "integer", "minimum": 1, "default": 100},
+ },
+ "required": ["scrubber_id", "track_number", "start_seconds"],
+ "additionalProperties": True,
+ },
+ },
+ {
+ "name": "CreateTrack",
+ "description": "Create a new empty track at the end of the timeline.",
+ "arguments": {
+ "type": "object",
+ "properties": {},
+ },
+ },
+ {
+ "name": "CreateTracks",
+ "description": "Create N new empty tracks at the end of the timeline.",
+ "arguments": {
+ "type": "object",
+ "properties": {
+ "count": {"type": "integer", "minimum": 1},
+ },
+ "required": ["count"],
+ },
+ },
+ {
+ "name": "PlaceAllAssetsParallel",
+ "description": "Place all media bin assets each on a separate track in parallel starting at start_seconds.",
+ "arguments": {
+ "type": "object",
+ "properties": {
+ "start_seconds": {"type": "number", "default": 0},
+ "duration_seconds": {"type": "number"},
+ "pixels_per_second": {"type": "integer", "minimum": 1, "default": 100},
+ },
+ },
+ },
+ {
+ "name": "AddMediaByName",
+ "description": "Add an asset by case-insensitive substring of its name to a track at a start time; optionally specify duration or end time.",
+ "arguments": {
+ "type": "object",
+ "properties": {
+ "scrubber_name": {"type": "string"},
+ "track_number": {"type": "integer", "minimum": 1},
+ "start_seconds": {"type": "number", "minimum": 0},
+ "duration_seconds": {"type": "number", "minimum": 0},
+ "end_seconds": {"type": "number", "minimum": 0},
+ "pixels_per_second": {"type": "integer", "minimum": 1, "default": 100},
+ },
+ "required": ["scrubber_name", "track_number", "start_seconds"],
+ "additionalProperties": True,
+ },
+ },
+ {
+ "name": "MoveScrubber",
+ "description": "Move an existing scrubber to a new time and optionally a new track.",
+ "arguments": {
+ "type": "object",
+ "properties": {
+ "scrubber_id": {"type": "string"},
+ "new_position_seconds": {"type": "number", "minimum": 0},
+ "new_track_number": {"type": "integer", "minimum": 1},
+ "pixels_per_second": {"type": "integer", "minimum": 1, "default": 100},
+ },
+ "required": ["scrubber_id", "new_position_seconds", "new_track_number"],
+ },
+ },
+ {
+ "name": "ResizeScrubber",
+ "description": "Change the duration of an existing scrubber.",
+ "arguments": {
+ "type": "object",
+ "properties": {
+ "scrubber_id": {"type": "string"},
+ "new_duration_seconds": {"type": "number", "minimum": 0},
+ "pixels_per_second": {"type": "integer", "minimum": 1, "default": 100},
+ },
+ "required": ["scrubber_id", "new_duration_seconds"],
+ },
+ },
+ {
+ "name": "MoveScrubbersByOffset",
+ "description": "Move multiple scrubbers by a time offset (can be negative).",
+ "arguments": {
+ "type": "object",
+ "properties": {
+ "scrubber_ids": {"type": "array", "items": {"type": "string"}},
+ "offset_seconds": {"type": "number"},
+ "pixels_per_second": {"type": "integer", "minimum": 1, "default": 100},
+ },
+ "required": ["scrubber_ids", "offset_seconds"],
+ },
+ },
+ {
+ "name": "DeleteScrubbersInTrack",
+ "description": "Delete all scrubbers in a given track (1-based).",
+ "arguments": {
+ "type": "object",
+ "properties": {
+ "track_number": {"type": "integer", "minimum": 1},
+ },
+ "required": ["track_number"],
+ },
+ },
+ {
+ "name": "UpdateTextContent",
+ "description": "Update the text content of a text scrubber.",
+ "arguments": {
+ "type": "object",
+ "properties": {
+ "scrubber_id": {"type": "string"},
+ "new_text_content": {"type": "string"},
+ },
+ "required": ["scrubber_id", "new_text_content"],
+ },
+ },
+ {
+ "name": "UpdateTextStyle",
+ "description": "Update style properties of a text scrubber.",
+ "arguments": {
+ "type": "object",
+ "properties": {
+ "scrubber_id": {"type": "string"},
+ "fontSize": {"type": "integer", "minimum": 1},
+ "fontFamily": {"type": "string"},
+ "color": {"type": "string"},
+ "textAlign": {"type": "string", "enum": ["left", "center", "right"]},
+ "fontWeight": {"type": "string", "enum": ["normal", "bold"]},
+ },
+ "required": ["scrubber_id"],
+ "additionalProperties": True,
+ },
+ },
+ {
+ "name": "SetResolution",
+ "description": "Set the project resolution (width x height).",
+ "arguments": {
+ "type": "object",
+ "properties": {
+ "width": {"type": "integer", "minimum": 1},
+ "height": {"type": "integer", "minimum": 1},
+ },
+ "required": ["width", "height"],
+ },
+ },
+ {
+ "name": "SetAutoSize",
+ "description": "Toggle autosize for the composition (overrides explicit resolution when true).",
+ "arguments": {
+ "type": "object",
+ "properties": {
+ "auto": {"type": "boolean"},
+ },
+ "required": ["auto"],
+ },
+ },
+ ]
+
+
+def get_tools_catalog_json() -> str:
+ """Return a compact JSON string of the catalog for embedding into prompts."""
+ return json.dumps(get_tools_catalog(), separators=(",", ":"))
+
+
diff --git a/backend/uv.lock b/backend/uv.lock
index 6197daf..4eb8f93 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -42,6 +42,7 @@ dependencies = [
{ name = "fastapi", extra = ["standard"] },
{ name = "google-genai" },
{ name = "mypy" },
+ { name = "parsedatetime" },
{ name = "python-multipart" },
{ name = "ruff" },
]
@@ -58,6 +59,7 @@ requires-dist = [
{ name = "google-genai", specifier = ">=1.22.0" },
{ name = "mypy", specifier = ">=1.16.1" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" },
+ { name = "parsedatetime", specifier = ">=2.6,<3.0" },
{ name = "python-multipart", specifier = ">=0.0.22" },
{ name = "ruff", specifier = ">=0.12.1" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.0" },
@@ -501,6 +503,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
+[[package]]
+name = "parsedatetime"
+version = "2.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a8/20/cb587f6672dbe585d101f590c3871d16e7aec5a576a1694997a3777312ac/parsedatetime-2.6.tar.gz", hash = "sha256:4cb368fbb18a0b7231f4d76119165451c8d2e35951455dfee97c62a87b04d455", size = 60114, upload-time = "2020-05-31T23:50:57.443Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/a4/3dd804926a42537bf69fb3ebb9fd72a50ba84f807d95df5ae016606c976c/parsedatetime-2.6-py3-none-any.whl", hash = "sha256:cb96edd7016872f58479e35879294258c71437195760746faffedb692aef000b", size = 42548, upload-time = "2020-05-31T23:50:56.315Z" },
+]
+
[[package]]
name = "pathspec"
version = "0.12.1"
@@ -512,11 +523,11 @@ wheels = [
[[package]]
name = "pyasn1"
-version = "0.6.2"
+version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
]
[[package]]
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
new file mode 100644
index 0000000..a647e4e
--- /dev/null
+++ b/docker-compose.dev.yml
@@ -0,0 +1,49 @@
+version: "3.8"
+
+services:
+ frontend:
+ build:
+ context: .
+ dockerfile: Dockerfile.frontend
+ args:
+ VITE_SUPABASE_URL: ${VITE_SUPABASE_URL}
+ VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY}
+ container_name: videoeditor-frontend
+ env_file:
+ - .env
+ environment:
+ # Development-specific overrides
+ PROD_DOMAIN: localhost
+ AUTH_BASE_URL: http://localhost:5173
+ AUTH_TRUSTED_ORIGINS: http://localhost:5173,http://127.0.0.1:5173
+ AUTH_COOKIE_DOMAIN: localhost
+ NODE_ENV: development
+ ports:
+ - "5173:5173"
+ depends_on:
+ - backend
+
+ backend:
+ build:
+ context: .
+ dockerfile: Dockerfile.backend
+ container_name: videoeditor-backend
+ env_file:
+ - .env
+ environment:
+ # Development-specific overrides
+ PROD_DOMAIN: localhost
+ AUTH_BASE_URL: http://localhost:5173
+ AUTH_TRUSTED_ORIGINS: http://localhost:5173,http://127.0.0.1:5173
+ AUTH_COOKIE_DOMAIN: localhost
+ NODE_ENV: development
+ ports:
+ - "8000:8000"
+
+ fastapi:
+ ports:
+ - "3000:3000"
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ container_name: videoeditor-fastapi
diff --git a/docker-compose.yml b/docker-compose.yml
index f0c0cb9..7bf784d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -7,7 +7,7 @@ services:
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- - /etc/letsencrypt:/etc/letsencrypt:ro # Mount certs read-only
+ - /etc/letsencrypt:/etc/letsencrypt:ro # Mount certs read-only
depends_on:
- frontend
- backend
@@ -29,9 +29,9 @@ services:
AUTH_COOKIE_DOMAIN: trykimu.com
NODE_ENV: production
HOST: 0.0.0.0
- PORT: 3000
+ PORT: 5173
# ports:
- # - "3000:3000"
+ # - "5173:5173"
depends_on:
- backend
@@ -61,4 +61,4 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
- container_name: videoeditor-fastapi
\ No newline at end of file
+ container_name: videoeditor-fastapi
diff --git a/nginx.conf b/nginx.conf
index 5548909..b0439d8 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -6,7 +6,7 @@ http {
client_max_body_size 500M;
upstream frontend {
- server frontend:3000;
+ server frontend:5173;
}
upstream backend {
@@ -61,6 +61,24 @@ http {
proxy_request_buffering off;
}
+ # trykimu.com/api/assets → http://frontend/api/assets (authenticated asset access)
+ location /api/assets {
+ proxy_pass http://frontend;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 900s;
+ proxy_send_timeout 900s;
+ proxy_request_buffering off;
+ client_max_body_size 500M;
+ }
+
+ # Block direct access to /media/* - all asset access must go through authenticated API
+ location /media {
+ return 403;
+ }
+
# trykimu.com/learn → http://frontend/learn
location / {
proxy_pass http://frontend;
diff --git a/package.json b/package.json
index 2c12d41..f522704 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"format:check": "prettier --check ."
},
"dependencies": {
+ "@alwatr/parse-duration": "^5.5.9",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6acb83b..05520fb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -30,6 +30,9 @@ importers:
.:
dependencies:
+ '@alwatr/parse-duration':
+ specifier: ^5.5.9
+ version: 5.5.9
'@radix-ui/react-alert-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -244,6 +247,15 @@ importers:
packages:
+ '@alwatr/is-number@5.7.5':
+ resolution: {integrity: sha512-5LRovbg7YEsdfkzQDPAk4LJTcfnMDiELdi0mJALlTq/p8n668Qm+sGw42psbfeRWD0ZWVy+jfRLIT0ah3Eq1+A==}
+
+ '@alwatr/package-tracer@5.5.8':
+ resolution: {integrity: sha512-yVbWTrEe1RaXRfwOxRtfG+nTGSnTUiWdXj1X4IYVFvefcGgvVXv4BiEUgGu0bXRYuzIAUgX1VSnPKePt4jbzNw==}
+
+ '@alwatr/parse-duration@5.5.9':
+ resolution: {integrity: sha512-TfksWQZdJNMm3cF3vR8eb1OlHcicDwnnr2YdxeSJD44cgmSUd6ZbJoZpS2Vsj+prSA4BBXSRFYxuvNel9BSSwg==}
+
'@ampproject/remapping@2.3.0':
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
@@ -2080,11 +2092,11 @@ packages:
peerDependencies:
ajv: ^8.8.2
- ajv@6.12.6:
- resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
+ ajv@6.14.0:
+ resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
- ajv@8.17.1:
- resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
+ ajv@8.18.0:
+ resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
@@ -4484,6 +4496,17 @@ packages:
snapshots:
+ '@alwatr/is-number@5.7.5':
+ dependencies:
+ '@alwatr/package-tracer': 5.5.8
+
+ '@alwatr/package-tracer@5.5.8': {}
+
+ '@alwatr/parse-duration@5.5.9':
+ dependencies:
+ '@alwatr/is-number': 5.7.5
+ '@alwatr/package-tracer': 5.5.8
+
'@ampproject/remapping@2.3.0':
dependencies:
'@jridgewell/gen-mapping': 0.3.12
@@ -4958,7 +4981,7 @@ snapshots:
'@eslint/eslintrc@3.3.1':
dependencies:
- ajv: 6.12.6
+ ajv: 6.14.0
debug: 4.4.1
espree: 10.4.0
globals: 14.0.0
@@ -6272,27 +6295,27 @@ snapshots:
acorn@8.15.0: {}
- ajv-formats@2.1.1(ajv@8.17.1):
+ ajv-formats@2.1.1(ajv@8.18.0):
optionalDependencies:
- ajv: 8.17.1
+ ajv: 8.18.0
- ajv-keywords@3.5.2(ajv@6.12.6):
+ ajv-keywords@3.5.2(ajv@6.14.0):
dependencies:
- ajv: 6.12.6
+ ajv: 6.14.0
- ajv-keywords@5.1.0(ajv@8.17.1):
+ ajv-keywords@5.1.0(ajv@8.18.0):
dependencies:
- ajv: 8.17.1
+ ajv: 8.18.0
fast-deep-equal: 3.1.3
- ajv@6.12.6:
+ ajv@6.14.0:
dependencies:
fast-deep-equal: 3.1.3
fast-json-stable-stringify: 2.1.0
json-schema-traverse: 0.4.1
uri-js: 4.4.1
- ajv@8.17.1:
+ ajv@8.18.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.0.6
@@ -6993,7 +7016,7 @@ snapshots:
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8
'@types/json-schema': 7.0.15
- ajv: 6.12.6
+ ajv: 6.14.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.1
@@ -8276,15 +8299,15 @@ snapshots:
schema-utils@3.3.0:
dependencies:
'@types/json-schema': 7.0.15
- ajv: 6.12.6
- ajv-keywords: 3.5.2(ajv@6.12.6)
+ ajv: 6.14.0
+ ajv-keywords: 3.5.2(ajv@6.14.0)
schema-utils@4.3.3:
dependencies:
'@types/json-schema': 7.0.15
- ajv: 8.17.1
- ajv-formats: 2.1.1(ajv@8.17.1)
- ajv-keywords: 5.1.0(ajv@8.17.1)
+ ajv: 8.18.0
+ ajv-formats: 2.1.1(ajv@8.18.0)
+ ajv-keywords: 5.1.0(ajv@8.18.0)
semver@6.3.1: {}