feat: enhance canvas functionality with new asset node type and improved image handling

- Introduced a new "asset" node type in the canvas sidebar for better resource management.
- Updated image node components to support dynamic image dimensions and improved resizing logic.
- Enhanced prompt and AI image nodes to utilize reference images from asset nodes, improving integration and functionality.
- Refactored canvas utilities to accommodate new asset configurations and maintain consistent media handling.
This commit is contained in:
Matthias
2026-03-27 20:33:20 +01:00
parent 6e38e2d270
commit bc3bbf9d69
14 changed files with 1059 additions and 189 deletions

View File

@@ -14,6 +14,7 @@ import type * as canvases from "../canvases.js";
import type * as credits from "../credits.js";
import type * as edges from "../edges.js";
import type * as export_ from "../export.js";
import type * as freepik from "../freepik.js";
import type * as helpers from "../helpers.js";
import type * as http from "../http.js";
import type * as nodes from "../nodes.js";
@@ -34,6 +35,7 @@ declare const fullApi: ApiFromModules<{
credits: typeof credits;
edges: typeof edges;
export: typeof export_;
freepik: typeof freepik;
helpers: typeof helpers;
http: typeof http;
nodes: typeof nodes;

View File

@@ -162,6 +162,7 @@ export const generateImage = action({
nodeId: v.id("nodes"),
prompt: v.string(),
referenceStorageId: v.optional(v.id("_storage")),
referenceImageUrl: v.optional(v.string()),
model: v.optional(v.string()),
aspectRatio: v.optional(v.string()),
},
@@ -200,7 +201,7 @@ export const generateImage = action({
let retryCount = 0;
try {
let referenceImageUrl: string | undefined;
let referenceImageUrl = args.referenceImageUrl?.trim() || undefined;
if (args.referenceStorageId) {
referenceImageUrl =
(await ctx.storage.getUrl(args.referenceStorageId)) ?? undefined;

169
convex/freepik.ts Normal file
View File

@@ -0,0 +1,169 @@
"use node";
import { v } from "convex/values";
import { action } from "./_generated/server";
const FREEPIK_BASE = "https://api.freepik.com";
type AssetType = "photo" | "vector" | "icon";
interface FreepikResult {
id: number;
title: string;
assetType: AssetType;
previewUrl: string;
intrinsicWidth?: number;
intrinsicHeight?: number;
sourceUrl: string;
license: "freemium" | "premium";
authorName: string;
orientation?: string;
}
interface FreepikSearchResponse {
results: FreepikResult[];
totalPages: number;
currentPage: number;
total: number;
}
function parseSize(size?: string): { width?: number; height?: number } {
if (!size) return {};
const match = size.match(/^(\d+)x(\d+)$/i);
if (!match) return {};
const width = Number(match[1]);
const height = Number(match[2]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return {};
}
return { width, height };
}
export const search = action({
args: {
term: v.string(),
assetType: v.union(v.literal("photo"), v.literal("vector"), v.literal("icon")),
page: v.optional(v.number()),
limit: v.optional(v.number()),
},
handler: async (_ctx, args): Promise<FreepikSearchResponse> => {
const apiKey = process.env.FREEPIK_API_KEY;
if (!apiKey) {
throw new Error("FREEPIK_API_KEY not set");
}
const page = args.page ?? 1;
const limit = args.limit ?? 20;
const params = new URLSearchParams({
term: args.term,
page: String(page),
order: "relevance",
"filters[license][freemium]": "1",
});
let endpoint = `${FREEPIK_BASE}/v1/resources`;
if (args.assetType === "icon") {
endpoint = `${FREEPIK_BASE}/v1/icons`;
params.set("per_page", String(limit));
} else {
params.set("limit", String(limit));
params.set(`filters[content_type][${args.assetType}]`, "1");
}
const res = await fetch(`${endpoint}?${params.toString()}`, {
headers: {
"x-freepik-api-key": apiKey,
Accept: "application/json",
},
});
if (!res.ok) {
throw new Error(`Freepik API error: ${res.status} ${res.statusText}`);
}
const json = (await res.json()) as {
data?: Array<{
id?: number;
title?: string;
url?: string;
image?: {
orientation?: string;
source?: {
url?: string;
size?: string;
};
};
licenses?: Array<{ type?: string }>;
author?: { name?: string };
}>;
meta?: {
total?: number;
current_page?: number;
last_page?: number;
total_pages?: number;
pagination?: {
total?: number;
current_page?: number;
last_page?: number;
total_pages?: number;
};
};
};
const data = json.data ?? [];
const pagination = json.meta?.pagination;
const results = data
.map((item): FreepikResult | null => {
if (!item.id || !item.image?.source?.url || !item.url) {
return null;
}
const license = item.licenses?.some((entry) => entry.type === "freemium")
? "freemium"
: "premium";
const parsedSize = parseSize(item.image?.source?.size);
return {
id: item.id,
title: item.title ?? "Untitled",
assetType: args.assetType,
previewUrl: item.image.source.url,
intrinsicWidth: parsedSize.width,
intrinsicHeight: parsedSize.height,
sourceUrl: item.url,
license,
authorName: item.author?.name ?? "Freepik",
orientation: item.image.orientation,
};
})
.filter((entry): entry is FreepikResult => entry !== null);
const totalPagesRaw =
pagination?.last_page ??
pagination?.total_pages ??
json.meta?.last_page ??
json.meta?.total_pages ??
1;
const currentPageRaw = pagination?.current_page ?? json.meta?.current_page ?? page;
const totalRaw = pagination?.total ?? json.meta?.total ?? results.length;
const totalPages =
Number.isFinite(totalPagesRaw) && totalPagesRaw > 0
? Math.floor(totalPagesRaw)
: 1;
const currentPage =
Number.isFinite(currentPageRaw) && currentPageRaw > 0
? Math.min(Math.floor(currentPageRaw), totalPages)
: page;
const total = Number.isFinite(totalRaw) && totalRaw >= 0 ? Math.floor(totalRaw) : results.length;
return {
results,
totalPages,
currentPage,
total,
};
},
});