feat(editor+trimmer): save output to cloud account via V2 File service
- New /api/files/upload: generic user-scoped Browser→Next→MinIO upload (presign → PUT → confirm), 200MB cap, image+video only, returns public URL - image-editor-export: stageToBlob() + saveStageToCloud(); "Save to my account" button in the Image Editor export popover - Trimmer: "Save to my account" button uploads the trimmed clip blob - i18n: saveToCloud/savingToCloud/savedToCloud/saveToCloudFailed in fa+en (parity 1002/1002) Connects the two client-side editors to V2 storage — output now lands in the user's account instead of only a local download. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { gatewayUrl } from "@/lib/api/gateway";
|
||||
import { getAccessToken } from "@/lib/auth/session";
|
||||
import { MINIO_PUBLIC_URL } from "@/lib/files";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Generous cap for editor/trimmer output (trimmed clips, high-res exports).
|
||||
const MAX_BYTES = 200 * 1024 * 1024; // 200 MB
|
||||
|
||||
/**
|
||||
* Generic user-scoped upload: Browser → Next → MinIO (presign → PUT → confirm).
|
||||
*
|
||||
* Unlike /api/profile/upload (avatar-only, persists to Identity), this just stores
|
||||
* the file in the user's `user-uploads` bucket and returns the public URL. Used by
|
||||
* the Image Editor "Save to cloud" and the Video Trimmer "Save to cloud" actions so
|
||||
* a user's work lands in their account instead of only a local download.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const token = await getAccessToken();
|
||||
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const form = await req.formData().catch(() => null);
|
||||
const file = form?.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
return NextResponse.json({ error: "File too large (max 200MB)" }, { status: 413 });
|
||||
}
|
||||
const mime = file.type || "application/octet-stream";
|
||||
if (!mime.startsWith("image/") && !mime.startsWith("video/")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only image or video files are allowed" },
|
||||
{ status: 415 }
|
||||
);
|
||||
}
|
||||
|
||||
const auth = { Authorization: `Bearer ${token}` };
|
||||
|
||||
// 1. presigned PUT URL
|
||||
const presignRes = await fetch(gatewayUrl("/v1/files/presigned-upload"), {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: { ...auth, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
filename: file.name,
|
||||
mime_type: mime,
|
||||
size_bytes: file.size,
|
||||
}),
|
||||
});
|
||||
const presign = await presignRes.json().catch(() => null);
|
||||
if (!presignRes.ok || !presign?.upload_url || !presign?.file_id) {
|
||||
return NextResponse.json(
|
||||
{ error: presign?.error?.message ?? "Could not start upload" },
|
||||
{ status: presignRes.status || 502 }
|
||||
);
|
||||
}
|
||||
|
||||
// 2. PUT the bytes to MinIO (server-side; reaches minio:9000)
|
||||
const put = await fetch(presign.upload_url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": mime },
|
||||
body: Buffer.from(await file.arrayBuffer()),
|
||||
});
|
||||
if (!put.ok) {
|
||||
return NextResponse.json({ error: "Upload to storage failed" }, { status: 502 });
|
||||
}
|
||||
|
||||
// 3. confirm
|
||||
await fetch(gatewayUrl(`/v1/files/${presign.file_id}/confirm`), {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: auth,
|
||||
});
|
||||
|
||||
// 4. resolve the public URL
|
||||
const detailRes = await fetch(gatewayUrl(`/v1/files/${presign.file_id}`), {
|
||||
cache: "no-store",
|
||||
headers: auth,
|
||||
});
|
||||
const detail = await detailRes.json().catch(() => null);
|
||||
const bucket = detail?.minio_bucket ?? "user-uploads";
|
||||
const key = detail?.minio_key;
|
||||
const url = key ? `${MINIO_PUBLIC_URL}/${bucket}/${key}` : null;
|
||||
|
||||
return NextResponse.json({ id: presign.file_id, name: file.name, mime_type: mime, url });
|
||||
}
|
||||
@@ -3,13 +3,13 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { Download, FolderOpen, Sparkles } from "lucide-react";
|
||||
import { CloudUpload, Download, FolderOpen, Sparkles } from "lucide-react";
|
||||
|
||||
import { ProjectSaveIndicator } from "@/components/studio/ProjectSaveIndicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { downloadStageImage } from "@/lib/image-editor-export";
|
||||
import { downloadStageImage, saveStageToCloud } from "@/lib/image-editor-export";
|
||||
import { getImageEditorStage } from "@/lib/image-editor-stage-ref";
|
||||
import type { ExportImageFormat } from "@/lib/image-editor-types";
|
||||
import type { ProjectSaveStatus } from "@/lib/project-save-status";
|
||||
@@ -32,6 +32,7 @@ export function ImageEditorTopBar({
|
||||
const t = useTranslations("auto.componentsImageEditorImageEditorTopBar");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [savingCloud, setSavingCloud] = useState(false);
|
||||
|
||||
const loadBaseImage = useImageEditorStore((s) => s.loadBaseImage);
|
||||
const exportFormat = useImageEditorStore((s) => s.exportFormat);
|
||||
@@ -62,6 +63,24 @@ export function ImageEditorTopBar({
|
||||
setExportOpen(false);
|
||||
};
|
||||
|
||||
const handleSaveToCloud = async () => {
|
||||
const stage = getImageEditorStage();
|
||||
if (!stage) {
|
||||
toast({ title: t("canvasNotReady") });
|
||||
return;
|
||||
}
|
||||
setSavingCloud(true);
|
||||
try {
|
||||
const url = await saveStageToCloud(stage, exportFormat, exportQuality);
|
||||
toast({ title: url ? t("savedToCloud") : t("saveToCloudFailed") });
|
||||
if (url) setExportOpen(false);
|
||||
} catch {
|
||||
toast({ title: t("saveToCloudFailed") });
|
||||
} finally {
|
||||
setSavingCloud(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center justify-between gap-4 border-b border-gray-800 bg-gray-900 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -154,8 +173,19 @@ export function ImageEditorTopBar({
|
||||
className="w-full bg-primary-600 hover:bg-primary-700"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t("download")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-2 w-full border-gray-700 bg-gray-800 text-gray-200"
|
||||
onClick={handleSaveToCloud}
|
||||
disabled={savingCloud}
|
||||
>
|
||||
<CloudUpload className="h-4 w-4" />
|
||||
{savingCloud ? t("savingToCloud") : t("saveToCloud")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { CloudUpload, Download, Loader2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import type { ExportFormat } from "@/lib/trimmer-types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -29,6 +31,29 @@ export function TrimmerExportSection({
|
||||
onProcess,
|
||||
}: TrimmerExportSectionProps) {
|
||||
const t = useTranslations("auto.componentsTrimmerTrimmerExportSection");
|
||||
const [savingCloud, setSavingCloud] = useState(false);
|
||||
|
||||
const handleSaveToCloud = async () => {
|
||||
if (!outputUrl) return;
|
||||
setSavingCloud(true);
|
||||
try {
|
||||
const blob = await fetch(outputUrl).then((r) => r.blob());
|
||||
const fd = new FormData();
|
||||
const mime = exportFormat === "webm" ? "video/webm" : "video/mp4";
|
||||
fd.append(
|
||||
"file",
|
||||
new File([blob], `trimmed-${Date.now()}.${exportFormat}`, { type: mime })
|
||||
);
|
||||
const res = await fetch("/api/files/upload", { method: "POST", body: fd });
|
||||
const data = (await res.json().catch(() => null)) as { url?: string } | null;
|
||||
toast({ title: res.ok && data?.url ? t("savedToCloud") : t("saveToCloudFailed") });
|
||||
} catch {
|
||||
toast({ title: t("saveToCloudFailed") });
|
||||
} finally {
|
||||
setSavingCloud(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-800 bg-gray-900 p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-sm font-semibold text-white">{t("heading")}</h2>
|
||||
@@ -104,6 +129,20 @@ export function TrimmerExportSection({
|
||||
<Download className="h-4 w-4" aria-hidden />
|
||||
{t("download", { format: exportFormat.toUpperCase() })}
|
||||
</a>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full border-gray-700 bg-gray-800 text-white hover:bg-gray-700"
|
||||
onClick={handleSaveToCloud}
|
||||
disabled={savingCloud}
|
||||
>
|
||||
{savingCloud ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<CloudUpload className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
{savingCloud ? t("savingToCloud") : t("saveToCloud")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -25,3 +25,46 @@ export function downloadStageImage(
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
}
|
||||
|
||||
/** Render the stage to a Blob at 2× pixel ratio for upload. */
|
||||
export async function stageToBlob(
|
||||
stage: Konva.Stage,
|
||||
format: ExportImageFormat,
|
||||
quality: number
|
||||
): Promise<Blob | null> {
|
||||
const mimeType =
|
||||
format === "png"
|
||||
? "image/png"
|
||||
: format === "jpg"
|
||||
? "image/jpeg"
|
||||
: "image/webp";
|
||||
|
||||
const dataUrl = stage.toDataURL({
|
||||
pixelRatio: 2,
|
||||
mimeType,
|
||||
quality: format === "png" ? 1 : quality / 100,
|
||||
});
|
||||
const res = await fetch(dataUrl);
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the stage and upload it to the user's cloud library (user-uploads bucket).
|
||||
* Returns the public URL on success, or null on failure.
|
||||
*/
|
||||
export async function saveStageToCloud(
|
||||
stage: Konva.Stage,
|
||||
format: ExportImageFormat,
|
||||
quality: number
|
||||
): Promise<string | null> {
|
||||
const blob = await stageToBlob(stage, format, quality);
|
||||
if (!blob) return null;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("file", blob, `design-${Date.now()}.${format}`);
|
||||
|
||||
const res = await fetch("/api/files/upload", { method: "POST", body: fd });
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json().catch(() => null)) as { url?: string } | null;
|
||||
return data?.url ?? null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user