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:
soroush.asadi
2026-06-05 12:29:03 +03:30
parent 52be5be93f
commit 1142c38c62
6 changed files with 224 additions and 7 deletions
+10 -2
View File
@@ -671,7 +671,11 @@
"trimAndCrop": "Trim & Crop", "trimAndCrop": "Trim & Crop",
"loadingEngine": "Loading FFmpeg engine…", "loadingEngine": "Loading FFmpeg engine…",
"progress": "Progress", "progress": "Progress",
"download": "Download {format}" "download": "Download {format}",
"saveToCloud": "Save to my account",
"savingToCloud": "Saving…",
"savedToCloud": "Saved to your account",
"saveToCloudFailed": "Could not save"
}, },
"componentsTrimmerTrimmerStrip": { "componentsTrimmerTrimmerStrip": {
"heading": "Trim", "heading": "Trim",
@@ -866,7 +870,11 @@
"quality": "Quality", "quality": "Quality",
"download": "Download", "download": "Download",
"canvasNotReady": "Canvas not ready.", "canvasNotReady": "Canvas not ready.",
"exportStarted": "Export started" "exportStarted": "Export started",
"saveToCloud": "Save to my account",
"savingToCloud": "Saving…",
"savedToCloud": "Saved to your account",
"saveToCloudFailed": "Could not save to your account"
}, },
"componentsImageEditorPanelsAdjustPanel": { "componentsImageEditorPanelsAdjustPanel": {
"emptyState": "Open an image to use adjustments.", "emptyState": "Open an image to use adjustments.",
+10 -2
View File
@@ -671,7 +671,11 @@
"trimAndCrop": "برش و کراپ", "trimAndCrop": "برش و کراپ",
"loadingEngine": "در حال بارگذاری موتور FFmpeg…", "loadingEngine": "در حال بارگذاری موتور FFmpeg…",
"progress": "پیشرفت", "progress": "پیشرفت",
"download": "دانلود {format}" "download": "دانلود {format}",
"saveToCloud": "ذخیره در حساب",
"savingToCloud": "در حال ذخیره…",
"savedToCloud": "در حساب شما ذخیره شد",
"saveToCloudFailed": "ذخیره ناموفق بود"
}, },
"componentsTrimmerTrimmerStrip": { "componentsTrimmerTrimmerStrip": {
"heading": "برش", "heading": "برش",
@@ -866,7 +870,11 @@
"quality": "کیفیت", "quality": "کیفیت",
"download": "دانلود", "download": "دانلود",
"canvasNotReady": "بوم آماده نیست.", "canvasNotReady": "بوم آماده نیست.",
"exportStarted": "خروجی‌گیری آغاز شد" "exportStarted": "خروجی‌گیری آغاز شد",
"saveToCloud": "ذخیره در حساب",
"savingToCloud": "در حال ذخیره…",
"savedToCloud": "در حساب شما ذخیره شد",
"saveToCloudFailed": "ذخیره در حساب ناموفق بود"
}, },
"componentsImageEditorPanelsAdjustPanel": { "componentsImageEditorPanelsAdjustPanel": {
"emptyState": "برای استفاده از تنظیمات، یک تصویر باز کنید.", "emptyState": "برای استفاده از تنظیمات، یک تصویر باز کنید.",
+89
View File
@@ -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 { useRef, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; 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 { ProjectSaveIndicator } from "@/components/studio/ProjectSaveIndicator";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { toast } from "@/components/ui/use-toast"; 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 { getImageEditorStage } from "@/lib/image-editor-stage-ref";
import type { ExportImageFormat } from "@/lib/image-editor-types"; import type { ExportImageFormat } from "@/lib/image-editor-types";
import type { ProjectSaveStatus } from "@/lib/project-save-status"; import type { ProjectSaveStatus } from "@/lib/project-save-status";
@@ -32,6 +32,7 @@ export function ImageEditorTopBar({
const t = useTranslations("auto.componentsImageEditorImageEditorTopBar"); const t = useTranslations("auto.componentsImageEditorImageEditorTopBar");
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
const [savingCloud, setSavingCloud] = useState(false);
const loadBaseImage = useImageEditorStore((s) => s.loadBaseImage); const loadBaseImage = useImageEditorStore((s) => s.loadBaseImage);
const exportFormat = useImageEditorStore((s) => s.exportFormat); const exportFormat = useImageEditorStore((s) => s.exportFormat);
@@ -62,6 +63,24 @@ export function ImageEditorTopBar({
setExportOpen(false); 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 ( return (
<header className="flex h-14 shrink-0 items-center justify-between gap-4 border-b border-gray-800 bg-gray-900 px-4"> <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"> <div className="flex items-center gap-3">
@@ -154,8 +173,19 @@ export function ImageEditorTopBar({
className="w-full bg-primary-600 hover:bg-primary-700" className="w-full bg-primary-600 hover:bg-primary-700"
onClick={handleExport} onClick={handleExport}
> >
<Download className="h-4 w-4" />
{t("download")} {t("download")}
</Button> </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> </div>
) : null} ) : null}
</div> </div>
@@ -1,9 +1,11 @@
"use client"; "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 { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { toast } from "@/components/ui/use-toast";
import type { ExportFormat } from "@/lib/trimmer-types"; import type { ExportFormat } from "@/lib/trimmer-types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -29,6 +31,29 @@ export function TrimmerExportSection({
onProcess, onProcess,
}: TrimmerExportSectionProps) { }: TrimmerExportSectionProps) {
const t = useTranslations("auto.componentsTrimmerTrimmerExportSection"); 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 ( return (
<section className="rounded-xl border border-gray-800 bg-gray-900 p-6 shadow-sm"> <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> <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 /> <Download className="h-4 w-4" aria-hidden />
{t("download", { format: exportFormat.toUpperCase() })} {t("download", { format: exportFormat.toUpperCase() })}
</a> </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> </div>
) : null} ) : null}
</section> </section>
+43
View File
@@ -25,3 +25,46 @@ export function downloadStageImage(
link.href = dataUrl; link.href = dataUrl;
link.click(); 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;
}