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.
This commit is contained in:
Matthias
2026-03-28 21:11:52 +01:00
parent 02f36fdc7b
commit cbfa14a40b
18 changed files with 1329 additions and 24 deletions

164
convex/pexels.ts Normal file
View File

@@ -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;
},
});