From cbfa14a40b57d35533f5fda6ef4e3065f8355fa9 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 28 Mar 2026 21:11:52 +0100 Subject: [PATCH] feat: enhance canvas functionality with scissors mode and node template updates - Implemented visual feedback and cursor changes for scissors mode in dark and light themes, improving user interaction during edge manipulation. - Updated node template picker to include new keywords for AI image generation, enhancing searchability. - Renamed and categorized node types for clarity, including updates to asset and prompt nodes. - Added support for video nodes and adjusted related components for improved media handling on the canvas. --- app/api/pexels-video/route.ts | 79 +++ app/globals.css | 56 +- .../canvas/canvas-node-template-picker.tsx | 2 +- components/canvas/canvas-sidebar.tsx | 5 +- components/canvas/canvas.tsx | 83 ++- components/canvas/node-types.ts | 2 + components/canvas/nodes/ai-image-node.tsx | 4 +- components/canvas/nodes/asset-node.tsx | 4 +- components/canvas/nodes/base-node-wrapper.tsx | 1 + components/canvas/nodes/video-node.tsx | 255 ++++++++ components/canvas/video-browser-panel.tsx | 564 ++++++++++++++++++ convex/_generated/api.d.ts | 2 + convex/pexels.ts | 164 +++++ lib/canvas-node-templates.ts | 2 +- lib/canvas-utils.ts | 3 + lib/pexels-types.ts | 81 +++ .../cursors/scissors-cursor-dark-canvas.svg | 23 + .../cursors/scissors-cursor-light-canvas.svg | 23 + 18 files changed, 1329 insertions(+), 24 deletions(-) create mode 100644 app/api/pexels-video/route.ts create mode 100644 components/canvas/nodes/video-node.tsx create mode 100644 components/canvas/video-browser-panel.tsx create mode 100644 convex/pexels.ts create mode 100644 lib/pexels-types.ts create mode 100644 public/cursors/scissors-cursor-dark-canvas.svg create mode 100644 public/cursors/scissors-cursor-light-canvas.svg diff --git a/app/api/pexels-video/route.ts b/app/api/pexels-video/route.ts new file mode 100644 index 0000000..1909b36 --- /dev/null +++ b/app/api/pexels-video/route.ts @@ -0,0 +1,79 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +const ALLOWED_HOSTS = new Set([ + "videos.pexels.com", + "player.vimeo.com", + "vod-progressive.pexels.com", +]); + +/** + * Proxies Pexels/Vimeo MP4 streams so playback works when the browser’s + * Referer (e.g. localhost) would be rejected by the CDN. + * Forwards Range for seeking; whitelists known video hosts from the Pexels API. + */ +export async function GET(request: NextRequest): Promise { + const raw = request.nextUrl.searchParams.get("u"); + if (!raw) { + return new NextResponse("Missing u", { status: 400 }); + } + + let target: URL; + try { + target = new URL(raw); + } catch { + return new NextResponse("Invalid URL", { status: 400 }); + } + + if (target.protocol !== "https:") { + return new NextResponse("HTTPS only", { status: 400 }); + } + + if (!ALLOWED_HOSTS.has(target.hostname)) { + return new NextResponse("Host not allowed", { status: 403 }); + } + + const upstreamHeaders: HeadersInit = { + Referer: "https://www.pexels.com/", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0", + }; + const range = request.headers.get("range"); + if (range) upstreamHeaders.Range = range; + const ifRange = request.headers.get("if-range"); + if (ifRange) upstreamHeaders["If-Range"] = ifRange; + + let upstream: Response; + try { + upstream = await fetch(target.toString(), { + headers: upstreamHeaders, + redirect: "follow", + }); + } catch { + return new NextResponse("Upstream fetch failed", { status: 502 }); + } + + const out = new Headers(); + const copy = [ + "content-type", + "content-length", + "accept-ranges", + "content-range", + "etag", + "last-modified", + "cache-control", + ] as const; + for (const name of copy) { + const v = upstream.headers.get(name); + if (v) out.set(name, v); + } + + if (!upstream.body) { + return new NextResponse(null, { status: upstream.status, headers: out }); + } + + return new NextResponse(upstream.body, { + status: upstream.status, + headers: out, + }); +} diff --git a/app/globals.css b/app/globals.css index daedb57..7702f2f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -203,12 +203,54 @@ stroke: rgba(189, 195, 199, 0.35); } - /* Scherenmodus: Scheren-Cursor (Teal, Fallback crosshair) */ - .react-flow.canvas-scissors-mode .react-flow__pane, - .react-flow.canvas-scissors-mode .react-flow__edge-interaction { - cursor: - url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%230d9488' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='6' cy='6' r='3'/%3E%3Ccircle cx='6' cy='18' r='3'/%3E%3Cpath d='M20 4 8.12 15.88M14.47 14.48 20 20M8.12 8.12 12 12'/%3E%3C/svg%3E") - 12 12, - crosshair; + /* + * Scherenmodus: Lucide „scissors-line-dashed“, 90° gegen Uhrzeigersinn (in SVG). + * Dunkles Flow: helle Outline + heller Glow · helles Flow: dunkle Outline + dunkler Glow. + */ + .react-flow.dark.canvas-scissors-mode .react-flow__pane, + .react-flow.dark.canvas-scissors-mode .react-flow__edge-interaction { + cursor: url("/cursors/scissors-cursor-dark-canvas.svg") 12 12, crosshair; + } + + .react-flow:not(.dark).canvas-scissors-mode .react-flow__pane, + .react-flow:not(.dark).canvas-scissors-mode .react-flow__edge-interaction { + cursor: url("/cursors/scissors-cursor-light-canvas.svg") 12 12, crosshair; + } + + /* Scherenmodus: Hover auf Verbindung = Aufleuchten (Farben wie Scheren-Cursor) */ + .react-flow.dark.canvas-scissors-mode .react-flow__edge:not(.temp) .react-flow__edge-path { + transition: + stroke 0.12s ease, + filter 0.12s ease; + } + + .react-flow.dark.canvas-scissors-mode + .react-flow__edge:not(.temp):hover + .react-flow__edge-path { + stroke: #ffffff !important; + filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.9)) + drop-shadow(0 0 10px rgba(255, 255, 255, 0.5)); + } + + .react-flow:not(.dark).canvas-scissors-mode + .react-flow__edge:not(.temp) + .react-flow__edge-path { + transition: + stroke 0.12s ease, + filter 0.12s ease; + } + + .react-flow:not(.dark).canvas-scissors-mode + .react-flow__edge:not(.temp):hover + .react-flow__edge-path { + stroke: #27272a !important; + filter: drop-shadow(0 0 2px rgba(39, 39, 42, 0.75)) + drop-shadow(0 0 9px rgba(39, 39, 42, 0.4)); + } + + @media (prefers-reduced-motion: reduce) { + .react-flow.canvas-scissors-mode .react-flow__edge:not(.temp) .react-flow__edge-path { + transition: none; + } } } diff --git a/components/canvas/canvas-node-template-picker.tsx b/components/canvas/canvas-node-template-picker.tsx index 3eda901..5c49322 100644 --- a/components/canvas/canvas-node-template-picker.tsx +++ b/components/canvas/canvas-node-template-picker.tsx @@ -30,7 +30,7 @@ const NODE_SEARCH_KEYWORDS: Partial< > = { image: ["image", "photo", "foto"], text: ["text", "typo"], - prompt: ["prompt", "ai", "generate"], + prompt: ["prompt", "ai", "generate", "ki-bild", "ki", "bild"], note: ["note", "sticky", "notiz"], frame: ["frame", "artboard"], compare: ["compare", "before", "after"], diff --git a/components/canvas/canvas-sidebar.tsx b/components/canvas/canvas-sidebar.tsx index 72b642a..2fbd022 100644 --- a/components/canvas/canvas-sidebar.tsx +++ b/components/canvas/canvas-sidebar.tsx @@ -2,9 +2,10 @@ const nodeTemplates = [ { type: "image", label: "Bild", icon: "🖼️", category: "Quelle" }, - { type: "asset", label: "Asset", icon: "🛍️", category: "Quelle" }, + { type: "asset", label: "FreePik", icon: "🛍️", category: "Quelle" }, + { type: "video", label: "Pexels", icon: "🎬", category: "Quelle" }, { type: "text", label: "Text", icon: "📝", category: "Quelle" }, - { type: "prompt", label: "Prompt", icon: "✨", category: "Quelle" }, + { type: "prompt", label: "KI-Bild", icon: "✨", category: "Quelle" }, { type: "note", label: "Notiz", icon: "📌", category: "Layout" }, { type: "frame", label: "Frame", icon: "🖥️", category: "Layout" }, { type: "group", label: "Gruppe", icon: "📁", category: "Layout" }, diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 21cb8b8..8ed77de 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -240,6 +240,49 @@ function isEdgeCuttable(edge: RFEdge): boolean { return true; } +/** Abstand in px zwischen Abtastpunkten beim Durchschneiden (kleiner = zuverlässiger bei schnellen Bewegungen). */ +const SCISSORS_SEGMENT_SAMPLE_STEP_PX = 4; + +function addCuttableEdgeIdAtClientPoint( + clientX: number, + clientY: number, + edgesList: RFEdge[], + strokeIds: Set, +): void { + const id = getIntersectedEdgeId({ x: clientX, y: clientY }); + if (!id) return; + const found = edgesList.find((e) => e.id === id); + if (found && isEdgeCuttable(found)) strokeIds.add(id); +} + +/** Alle Kanten erfassen, deren Hit-Zone die Strecke von (x0,y0) nach (x1,y1) schneidet. */ +function collectCuttableEdgesAlongScreenSegment( + x0: number, + y0: number, + x1: number, + y1: number, + edgesList: RFEdge[], + strokeIds: Set, +): void { + const dx = x1 - x0; + const dy = y1 - y0; + const dist = Math.hypot(dx, dy); + if (dist < 0.5) { + addCuttableEdgeIdAtClientPoint(x1, y1, edgesList, strokeIds); + return; + } + const steps = Math.max(1, Math.ceil(dist / SCISSORS_SEGMENT_SAMPLE_STEP_PX)); + for (let i = 0; i <= steps; i++) { + const t = i / steps; + addCuttableEdgeIdAtClientPoint( + x0 + dx * t, + y0 + dy * t, + edgesList, + strokeIds, + ); + } +} + function hasHandleKey( handles: { source?: string; target?: string } | undefined, key: "source" | "target", @@ -1703,13 +1746,29 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { (event: React.DragEvent) => { event.preventDefault(); - const nodeType = event.dataTransfer.getData( + const rawData = event.dataTransfer.getData( "application/lemonspace-node-type", ); - if (!nodeType) { + if (!rawData) { return; } + // Support both plain type string (sidebar) and JSON payload (browser panels) + let nodeType: string; + let payloadData: Record | undefined; + + try { + const parsed = JSON.parse(rawData); + if (typeof parsed === "object" && parsed.type) { + nodeType = parsed.type; + payloadData = parsed.data; + } else { + nodeType = rawData; + } + } catch { + nodeType = rawData; + } + const position = screenToFlowPosition({ x: event.clientX, y: event.clientY, @@ -1729,7 +1788,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { positionY: position.y, width: defaults.width, height: defaults.height, - data: { ...defaults.data, canvasId }, + data: { ...defaults.data, ...payloadData, canvasId }, clientRequestId, }).then((realId) => { syncPendingMoveForClientRequest(clientRequestId, realId); @@ -1798,13 +1857,19 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { setScissorStrokePreview(points); const handleMove = (ev: PointerEvent) => { - points.push({ x: ev.clientX, y: ev.clientY }); + const prev = points[points.length - 1]!; + const nx = ev.clientX; + const ny = ev.clientY; + collectCuttableEdgesAlongScreenSegment( + prev.x, + prev.y, + nx, + ny, + edgesRef.current, + strokeIds, + ); + points.push({ x: nx, y: ny }); setScissorStrokePreview([...points]); - const id = getIntersectedEdgeId({ x: ev.clientX, y: ev.clientY }); - if (id) { - const found = edgesRef.current.find((ed) => ed.id === id); - if (found && isEdgeCuttable(found)) strokeIds.add(id); - } }; const handleUp = () => { diff --git a/components/canvas/node-types.ts b/components/canvas/node-types.ts index 8573d49..93cd25e 100644 --- a/components/canvas/node-types.ts +++ b/components/canvas/node-types.ts @@ -7,6 +7,7 @@ import FrameNode from "./nodes/frame-node"; import NoteNode from "./nodes/note-node"; import CompareNode from "./nodes/compare-node"; import AssetNode from "./nodes/asset-node"; +import VideoNode from "./nodes/video-node"; /** * Node-Type-Map für React Flow. @@ -25,4 +26,5 @@ export const nodeTypes = { note: NoteNode, compare: CompareNode, asset: AssetNode, + video: VideoNode, } as const; diff --git a/components/canvas/nodes/ai-image-node.tsx b/components/canvas/nodes/ai-image-node.tsx index e348b9c..addba18 100644 --- a/components/canvas/nodes/ai-image-node.tsx +++ b/components/canvas/nodes/ai-image-node.tsx @@ -92,7 +92,7 @@ export default function AiImageNode({ if (!canvasId) throw new Error("Missing canvasId"); const prompt = nodeData.prompt; - if (!prompt) throw new Error("No prompt — use Generate from a Prompt node"); + if (!prompt) throw new Error("No prompt — use Generate from a KI-Bild node"); const edges = getEdges(); const incomingEdges = edges.filter((e) => e.target === id); @@ -187,7 +187,7 @@ export default function AiImageNode({

- Connect a Prompt node and click Generate + Verbinde einen KI-Bild-Knoten und starte die Generierung dort.

)} diff --git a/components/canvas/nodes/asset-node.tsx b/components/canvas/nodes/asset-node.tsx index b3dae54..88f4907 100644 --- a/components/canvas/nodes/asset-node.tsx +++ b/components/canvas/nodes/asset-node.tsx @@ -138,7 +138,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro >
- Asset + FreePik +
+ + {/* Content: flex-1 + min-h-0 keeps media inside the node; avoid aspect-ratio here (grid overflow). */} + {showPreview ? ( + <> +
+
+ + {/* Attribution */} + {d.attribution ? ( +
+
+ + by {d.attribution.userName} + + e.stopPropagation()} + > + Pexels + +
+
+ ) : ( +
+ )} + + ) : ( +
+
+ +
+
+

No video selected

+

+ Browse free stock videos from Pexels +

+
+
+ )} +
+ + {/* Video browser modal */} + {panelOpen && d.canvasId ? ( + setPanelOpen(false)} + /> + ) : null} + + + + ); +} diff --git a/components/canvas/video-browser-panel.tsx b/components/canvas/video-browser-panel.tsx new file mode 100644 index 0000000..1b8acf6 --- /dev/null +++ b/components/canvas/video-browser-panel.tsx @@ -0,0 +1,564 @@ +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MouseEvent, + type PointerEvent, +} from "react"; +import { createPortal } from "react-dom"; +import { useAction, useMutation } from "convex/react"; +import { X, Search, Loader2, AlertCircle, Play, Pause } from "lucide-react"; +import { api } from "@/convex/_generated/api"; +import type { Id } from "@/convex/_generated/dataModel"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import type { PexelsVideo, PexelsVideoFile } from "@/lib/pexels-types"; +import { pickPreviewVideoFile, pickVideoFile } from "@/lib/pexels-types"; +import { toast } from "@/lib/toast"; + +type Orientation = "" | "landscape" | "portrait" | "square"; +type DurationFilter = "all" | "short" | "medium" | "long"; + +function formatDuration(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s.toString().padStart(2, "0")}`; +} + +function pexelsVideoProxySrc(mp4Url: string): string { + return `/api/pexels-video?u=${encodeURIComponent(mp4Url)}`; +} + +export interface VideoBrowserSessionState { + term: string; + orientation: Orientation; + durationFilter: DurationFilter; + results: PexelsVideo[]; + page: number; + totalPages: number; +} + +interface Props { + nodeId: string; + canvasId: string; + onClose: () => void; + initialState?: VideoBrowserSessionState; + onStateChange?: (state: VideoBrowserSessionState) => void; +} + +export function VideoBrowserPanel({ + nodeId, + canvasId, + onClose, + initialState, + onStateChange, +}: Props) { + const [isMounted, setIsMounted] = useState(false); + const [term, setTerm] = useState(initialState?.term ?? ""); + const [debouncedTerm, setDebouncedTerm] = useState(initialState?.term ?? ""); + const [orientation, setOrientation] = useState( + initialState?.orientation ?? "", + ); + const [durationFilter, setDurationFilter] = useState( + initialState?.durationFilter ?? "all", + ); + const [results, setResults] = useState( + initialState?.results ?? [], + ); + const [page, setPage] = useState(initialState?.page ?? 1); + const [totalPages, setTotalPages] = useState(initialState?.totalPages ?? 1); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [selectingVideoId, setSelectingVideoId] = useState( + null, + ); + const [previewingVideoId, setPreviewingVideoId] = useState( + null, + ); + + const searchVideos = useAction(api.pexels.searchVideos); + const popularVideos = useAction(api.pexels.popularVideos); + const updateData = useMutation(api.nodes.updateData); + const resizeNode = useMutation(api.nodes.resize); + const shouldSkipInitialSearchRef = useRef( + Boolean(initialState?.results?.length), + ); + const requestSequenceRef = useRef(0); + const scrollAreaRef = useRef(null); + const isSelecting = selectingVideoId !== null; + + useEffect(() => { + setIsMounted(true); + return () => setIsMounted(false); + }, []); + + // Debounce + useEffect(() => { + const timeout = setTimeout(() => setDebouncedTerm(term), 400); + return () => clearTimeout(timeout); + }, [term]); + + useEffect(() => { + setPreviewingVideoId(null); + }, [debouncedTerm, orientation, durationFilter, page]); + + // Escape + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + // Session state sync + useEffect(() => { + if (!onStateChange) return; + onStateChange({ term, orientation, durationFilter, results, page, totalPages }); + }, [durationFilter, onStateChange, orientation, page, results, term, totalPages]); + + const getDurationParams = useCallback( + (filter: DurationFilter): { minDuration?: number; maxDuration?: number } => { + switch (filter) { + case "short": + return { maxDuration: 30 }; + case "medium": + return { minDuration: 30, maxDuration: 60 }; + case "long": + return { minDuration: 60 }; + default: + return {}; + } + }, + [], + ); + + const runSearch = useCallback( + async (searchTerm: string, requestedPage: number) => { + const seq = ++requestSequenceRef.current; + setIsLoading(true); + setErrorMessage(null); + + try { + const isSearch = searchTerm.trim().length > 0; + const durationParams = getDurationParams(durationFilter); + + const response = isSearch + ? await searchVideos({ + query: searchTerm, + orientation: orientation || undefined, + page: requestedPage, + perPage: 20, + ...durationParams, + }) + : await popularVideos({ + page: requestedPage, + perPage: 20, + }); + + if (seq !== requestSequenceRef.current) return; + + const videos = response.videos ?? []; + setResults(videos); + setPage(requestedPage); + + // Estimate total pages from next_page presence + const estimatedTotal = response.total_results ?? videos.length * requestedPage; + const perPage = response.per_page ?? 20; + setTotalPages(Math.max(1, Math.ceil(estimatedTotal / perPage))); + + if (scrollAreaRef.current) scrollAreaRef.current.scrollTop = 0; + } catch (error) { + if (seq !== requestSequenceRef.current) return; + console.error("Pexels video search error", error); + setErrorMessage( + error instanceof Error ? error.message : "Search failed", + ); + } finally { + if (seq === requestSequenceRef.current) setIsLoading(false); + } + }, + [searchVideos, popularVideos, orientation, durationFilter, getDurationParams], + ); + + // Trigger search + useEffect(() => { + if (shouldSkipInitialSearchRef.current) { + shouldSkipInitialSearchRef.current = false; + return; + } + void runSearch(debouncedTerm, 1); + }, [debouncedTerm, orientation, durationFilter, runSearch]); + + const handleSelect = useCallback( + async (video: PexelsVideo) => { + if (isSelecting) return; + setSelectingVideoId(video.id); + let file: PexelsVideoFile; + try { + file = pickVideoFile(video.video_files); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Videoformat nicht verfügbar", + ); + setSelectingVideoId(null); + return; + } + try { + await updateData({ + nodeId: nodeId as Id<"nodes">, + data: { + pexelsId: video.id, + mp4Url: file.link, + thumbnailUrl: video.image, + width: video.width, + height: video.height, + duration: video.duration, + attribution: { + userName: video.user.name, + userUrl: video.user.url, + videoUrl: video.url, + }, + canvasId, + }, + }); + + // Auto-resize to match aspect ratio + const aspectRatio = + video.width > 0 && video.height > 0 + ? video.width / video.height + : 16 / 9; + const targetWidth = 320; + const targetHeight = Math.round(targetWidth / aspectRatio); + await resizeNode({ + nodeId: nodeId as Id<"nodes">, + width: targetWidth, + height: targetHeight, + }); + onClose(); + } catch (error) { + console.error("Failed to select video", error); + } finally { + setSelectingVideoId(null); + } + }, + [canvasId, isSelecting, nodeId, onClose, resizeNode, updateData], + ); + + const handlePreviousPage = useCallback(() => { + if (isLoading || page <= 1) return; + void runSearch(debouncedTerm, page - 1); + }, [debouncedTerm, isLoading, page, runSearch]); + + const handleNextPage = useCallback(() => { + if (isLoading || page >= totalPages) return; + void runSearch(debouncedTerm, page + 1); + }, [debouncedTerm, isLoading, page, runSearch, totalPages]); + + const modal = useMemo( + () => ( +
event.stopPropagation()} + onPointerDownCapture={(event) => event.stopPropagation()} + > +
event.stopPropagation()} + onWheelCapture={(event) => event.stopPropagation()} + onPointerDownCapture={(event) => event.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label="Browse Pexels videos" + > + {/* Header */} +
+

Browse Pexels Videos

+ +
+ + {/* Search + Filters */} +
+
+ + setTerm(event.target.value)} + className="pl-9" + autoFocus + /> +
+
+
+ Format: + {(["", "landscape", "portrait", "square"] as const).map((o) => ( + + ))} +
+
+ Dauer: + {( + [ + ["all", "Alle"], + ["short", "<30s"], + ["medium", "30–60s"], + ["long", ">60s"], + ] as const + ).map(([key, label]) => ( + + ))} +
+
+
+ + {/* Results */} +
event.stopPropagation()} + > + {isLoading && results.length === 0 ? ( +
+ {Array.from({ length: 12 }).map((_, index) => ( +
+ ))} +
+ ) : errorMessage ? ( +
+ +

Search failed

+

{errorMessage}

+ +
+ ) : results.length === 0 ? ( +
+ +

+ {term.trim() ? "Keine Videos gefunden" : "Videos werden geladen..."} +

+
+ ) : ( +
+ {results.map((video) => { + const isSelectingThis = selectingVideoId === video.id; + const previewFile = pickPreviewVideoFile(video.video_files); + const previewSrc = previewFile + ? pexelsVideoProxySrc(previewFile.link) + : null; + const isPreview = previewingVideoId === video.id; + const stopBubbling = (e: PointerEvent | MouseEvent) => { + e.stopPropagation(); + }; + return ( +
+ + {isPreview && previewSrc ? ( +
+ )} +
+ + {/* Footer */} +
+ {results.length > 0 ? ( +
+ + + Seite {page} von {totalPages} + + +
+ ) : null} +
+

+ Videos by{" "} + + Pexels + + . Free to use, attribution appreciated. +

+ + {results.length > 0 ? `${results.length} Videos` : ""} + +
+
+
+
+ ), + [ + debouncedTerm, + durationFilter, + errorMessage, + handleNextPage, + handlePreviousPage, + handleSelect, + isLoading, + isSelecting, + onClose, + orientation, + page, + results, + runSearch, + previewingVideoId, + selectingVideoId, + term, + totalPages, + ], + ); + + if (!isMounted) return null; + return createPortal(modal, document.body); +} diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 200ef6f..a9e86c8 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -19,6 +19,7 @@ import type * as helpers from "../helpers.js"; import type * as http from "../http.js"; import type * as nodes from "../nodes.js"; import type * as openrouter from "../openrouter.js"; +import type * as pexels from "../pexels.js"; import type * as polar from "../polar.js"; import type * as storage from "../storage.js"; @@ -40,6 +41,7 @@ declare const fullApi: ApiFromModules<{ http: typeof http; nodes: typeof nodes; openrouter: typeof openrouter; + pexels: typeof pexels; polar: typeof polar; storage: typeof storage; }>; diff --git a/convex/pexels.ts b/convex/pexels.ts new file mode 100644 index 0000000..fed03da --- /dev/null +++ b/convex/pexels.ts @@ -0,0 +1,164 @@ +"use node"; + +import { v } from "convex/values"; +import { action } from "./_generated/server"; + +/** Canonical API base (legacy /videos/ ohne /v1/ ist deprecated laut Pexels-Doku). */ +const PEXELS_VIDEO_API = "https://api.pexels.com/v1/videos"; + +interface PexelsVideoFile { + id: number; + quality: "hd" | "sd" | "uhd" | "hls"; + file_type: string; + width: number; + height: number; + fps: number; + link: string; +} + +interface PexelsVideo { + id: number; + width: number; + height: number; + url: string; + image: string; + duration: number; + user: { id: number; name: string; url: string }; + video_files: PexelsVideoFile[]; +} + +function pickPlayableVideoFile(files: PexelsVideoFile[]): PexelsVideoFile { + const playable = files.filter((f) => { + if (f.quality === "hls") return false; + const url = f.link.toLowerCase(); + if (url.includes(".m3u8")) return false; + return url.includes(".mp4"); + }); + if (playable.length === 0) { + throw new Error("No progressive MP4 in Pexels video_files"); + } + return ( + playable.find((f) => f.quality === "hd") ?? + playable.find((f) => f.quality === "uhd") ?? + playable.find((f) => f.quality === "sd") ?? + playable[0] + ); +} + +/** Frische MP4-URL (Signing kann ablaufen) — gleiche Auswahl wie beim ersten Pick. */ +export const getVideoByPexelsId = action({ + args: { pexelsId: v.number() }, + handler: async (_ctx, { pexelsId }) => { + const apiKey = process.env.PEXELS_API_KEY; + if (!apiKey) { + throw new Error("PEXELS_API_KEY not set"); + } + + const res = await fetch(`${PEXELS_VIDEO_API}/${pexelsId}`, { + headers: { Authorization: apiKey }, + }); + + if (!res.ok) { + throw new Error(`Pexels API error: ${res.status} ${res.statusText}`); + } + + const video = (await res.json()) as PexelsVideo; + const file = pickPlayableVideoFile(video.video_files); + return { + mp4Url: file.link, + width: video.width, + height: video.height, + duration: video.duration, + }; + }, +}); + +export const searchVideos = action({ + args: { + query: v.string(), + orientation: v.optional( + v.union( + v.literal("landscape"), + v.literal("portrait"), + v.literal("square"), + ), + ), + minDuration: v.optional(v.number()), + maxDuration: v.optional(v.number()), + page: v.optional(v.number()), + perPage: v.optional(v.number()), + }, + handler: async (_ctx, args) => { + const apiKey = process.env.PEXELS_API_KEY; + if (!apiKey) { + throw new Error("PEXELS_API_KEY not set"); + } + + const params = new URLSearchParams({ + query: args.query, + per_page: String(args.perPage ?? 20), + page: String(args.page ?? 1), + ...(args.orientation && { orientation: args.orientation }), + ...(args.minDuration != null && { + min_duration: String(args.minDuration), + }), + ...(args.maxDuration != null && { + max_duration: String(args.maxDuration), + }), + }); + + const res = await fetch(`${PEXELS_VIDEO_API}/search?${params}`, { + headers: { Authorization: apiKey }, + }); + + if (!res.ok) { + throw new Error(`Pexels API error: ${res.status} ${res.statusText}`); + } + + const data = (await res.json()) as { + videos: PexelsVideo[]; + total_results: number; + next_page?: string; + page?: number; + per_page?: number; + }; + + return data; + }, +}); + +export const popularVideos = action({ + args: { + page: v.optional(v.number()), + perPage: v.optional(v.number()), + }, + handler: async (_ctx, args) => { + const apiKey = process.env.PEXELS_API_KEY; + if (!apiKey) { + throw new Error("PEXELS_API_KEY not set"); + } + + const params = new URLSearchParams({ + per_page: String(args.perPage ?? 20), + page: String(args.page ?? 1), + }); + + const res = await fetch(`${PEXELS_VIDEO_API}/popular?${params}`, { + headers: { Authorization: apiKey }, + }); + + if (!res.ok) { + throw new Error(`Pexels API error: ${res.status} ${res.statusText}`); + } + + const data = (await res.json()) as { + videos: PexelsVideo[]; + total_results?: number; + next_page?: string; + page?: number; + per_page?: number; + }; + + return data; + }, +}); diff --git a/lib/canvas-node-templates.ts b/lib/canvas-node-templates.ts index f68f20b..352700b 100644 --- a/lib/canvas-node-templates.ts +++ b/lib/canvas-node-templates.ts @@ -15,7 +15,7 @@ export const CANVAS_NODE_TEMPLATES = [ }, { type: "prompt", - label: "Prompt", + label: "KI-Bild", width: 320, height: 220, defaultData: { prompt: "", model: "", aspectRatio: "1:1" }, diff --git a/lib/canvas-utils.ts b/lib/canvas-utils.ts index 2faa8ca..512a526 100644 --- a/lib/canvas-utils.ts +++ b/lib/canvas-utils.ts @@ -101,6 +101,7 @@ const SOURCE_NODE_GLOW_RGB: Record = text: [13, 148, 136], note: [13, 148, 136], asset: [13, 148, 136], + video: [13, 148, 136], group: [100, 116, 139], frame: [249, 115, 22], compare: [100, 116, 139], @@ -185,6 +186,7 @@ export const NODE_HANDLE_MAP: Record< note: { source: undefined, target: undefined }, compare: { source: "compare-out", target: "left" }, asset: { source: undefined, target: undefined }, + video: { source: undefined, target: undefined }, }; /** @@ -208,6 +210,7 @@ export const NODE_DEFAULTS: Record< note: { width: 208, height: 100, data: { content: "" } }, compare: { width: 500, height: 380, data: {} }, asset: { width: 260, height: 240, data: {} }, + video: { width: 320, height: 180, data: {} }, }; type MediaNodeKind = "asset" | "image"; diff --git a/lib/pexels-types.ts b/lib/pexels-types.ts new file mode 100644 index 0000000..132723b --- /dev/null +++ b/lib/pexels-types.ts @@ -0,0 +1,81 @@ +export interface PexelsVideoFile { + id: number; + /** Pexels liefert u. a. `hls` mit `.m3u8` — nicht für `