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:
+10
-2
@@ -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
@@ -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": "برای استفاده از تنظیمات، یک تصویر باز کنید.",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user