diff --git a/binding/app/service.go b/binding/app/service.go index f2a458a..33dfb07 100644 --- a/binding/app/service.go +++ b/binding/app/service.go @@ -1,6 +1,7 @@ package app import ( + "context" "encoding/json" "fmt" "io" @@ -10,6 +11,8 @@ import ( "visionflow/database" ) +var WailsContext *context.Context + type Service struct { WailsJSON string } diff --git a/binding/database/service.go b/binding/database/service.go index c0b7abf..54319d0 100644 --- a/binding/database/service.go +++ b/binding/database/service.go @@ -4,12 +4,16 @@ import ( "crypto/md5" "encoding/hex" "fmt" + "io" "os" "path/filepath" "time" + "visionflow/binding/app" db "visionflow/database" "visionflow/service/fileserver" "visionflow/storage" + + "github.com/wailsapp/wails/v2/pkg/runtime" ) type Service struct { @@ -142,3 +146,51 @@ func (s *Service) CreateAssetFromFile(name string, data []byte) (*db.Asset, erro createdAsset.URL = fileserver.GetFileUrl(createdAsset.Path) return createdAsset, nil } + +func (s *Service) DownloadAssetFile(filename string) error { + // 1. Resolve source path + assetsDir, err := storage.GetAssetsDir() + if err != nil { + return fmt.Errorf("failed to get assets directory: %w", err) + } + sourcePath := filepath.Join(assetsDir, filename) + + // Check if source file exists + if _, err := os.Stat(sourcePath); os.IsNotExist(err) { + return fmt.Errorf("source file does not exist: %s", filename) + } + + // 2. Open Save Dialog + destPath, err := runtime.SaveFileDialog(*app.WailsContext, runtime.SaveDialogOptions{ + DefaultFilename: filename, + Title: "Save Asset File", + }) + if err != nil { + return err + } + + // User cancelled + if destPath == "" { + return nil + } + + // 3. Copy File efficiently + source, err := os.Open(sourcePath) + if err != nil { + return fmt.Errorf("failed to open source file: %w", err) + } + defer source.Close() + + destination, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create destination file: %w", err) + } + defer destination.Close() + + _, err = io.Copy(destination, source) + if err != nil { + return fmt.Errorf("failed to save file: %w", err) + } + + return nil +} diff --git a/frontend/src/components/asset-library.tsx b/frontend/src/components/asset-library.tsx index 7816ffb..5011b2e 100644 --- a/frontend/src/components/asset-library.tsx +++ b/frontend/src/components/asset-library.tsx @@ -1,9 +1,9 @@ import { useState, useEffect, useRef } from "react"; import { database } from "../../wailsjs/go/models"; -import { ListAssets, DeleteAsset } from "../../wailsjs/go/database/Service"; +import { ListAssets, DeleteAsset, DownloadAssetFile } from "../../wailsjs/go/database/Service"; import { Card, CardContent, CardFooter } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { Trash2, FileImage, FileVideo, FileAudio } from "lucide-react"; +import { Trash2, FileImage, FileVideo, FileAudio, Download } from "lucide-react"; import { toast } from "sonner"; import { msg } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; @@ -53,7 +53,16 @@ export function AssetLibrary() { } }; - + const handleSave = async (e: React.MouseEvent, asset: database.Asset) => { + e.stopPropagation(); + try { + await DownloadAssetFile(asset.path); + toast.success(_(msg`Asset saved`)); + } catch (err) { + console.error("Failed to save asset:", err); + toast.error(_(msg`Failed to save`)); + } + }; return (
@@ -89,6 +98,14 @@ export function AssetLibrary() {
+ + + +
{nodeData.processing ? (
@@ -46,7 +89,9 @@ export const AudioNode = memo((props: NodeProps) => { ) : nodeData.audioUrl ? (
diff --git a/frontend/src/components/nodes/image-node.tsx b/frontend/src/components/nodes/image-node.tsx index 0cfef75..ad4dd33 100644 --- a/frontend/src/components/nodes/image-node.tsx +++ b/frontend/src/components/nodes/image-node.tsx @@ -1,18 +1,28 @@ import { memo } from "react"; -import { type NodeProps, useReactFlow } from "@xyflow/react"; -import { Image as ImageIcon } from "lucide-react"; +import { + type NodeProps, + NodeToolbar, + Position, + useReactFlow, +} from "@xyflow/react"; +import { Download, Image as ImageIcon } from "lucide-react"; import type { ImageNodeData } from "./types"; import { GenerateImage } from "../../../wailsjs/go/ai/Service"; +import { DownloadAssetFile } from "../../../wailsjs/go/database/Service"; import { Skeleton } from "@/components/ui/skeleton"; import { BaseNode } from "./base-node"; import { useNodeRun } from "../../hooks/use-node-run"; import { useLingui } from "@lingui/react"; import { msg } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; +import { ButtonGroup } from "../ui/button-group"; +import { Button } from "../ui/button"; +import { toast } from "sonner"; export const ImageNode = memo((props: NodeProps) => { const { id, data } = props; const nodeData = data as unknown as ImageNodeData; + const isSelected = props.selected; const { updateNodeData } = useReactFlow(); const { _ } = useLingui(); @@ -28,6 +38,20 @@ export const ImageNode = memo((props: NodeProps) => { }, }); + const handleSave = async () => { + if (nodeData.imageUrl == null) { + toast.error(_(msg`No image to save`)); + return; + } + try { + await DownloadAssetFile(nodeData.imageUrl.split("/").pop() || ""); + toast.success(_(msg`Asset saved`)); + } catch (err) { + console.error("Failed to save asset:", err); + toast.error(_(msg`Failed to save`)); + } + }; + return ( { minWidth={200} minHeight={200} > + + + + + +
{nodeData.processing ? (
diff --git a/frontend/src/components/nodes/video-node.tsx b/frontend/src/components/nodes/video-node.tsx index 0068ac5..104e2bd 100644 --- a/frontend/src/components/nodes/video-node.tsx +++ b/frontend/src/components/nodes/video-node.tsx @@ -1,6 +1,11 @@ import { memo, useRef, useState } from "react"; -import { type NodeProps, useReactFlow } from "@xyflow/react"; -import { Video, Play, Pause } from "lucide-react"; +import { + type NodeProps, + NodeToolbar, + Position, + useReactFlow, +} from "@xyflow/react"; +import { Video, Play, Pause, Download } from "lucide-react"; import type { VideoNodeData } from "./types"; import { GenerateVideo } from "../../../wailsjs/go/ai/Service"; import { Skeleton } from "@/components/ui/skeleton"; @@ -10,9 +15,13 @@ import { useNodeRun } from "../../hooks/use-node-run"; import { useLingui } from "@lingui/react"; import { msg } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; +import { ButtonGroup } from "../ui/button-group"; +import { toast } from "sonner"; +import { DownloadAssetFile } from "../../../wailsjs/go/database/Service"; export const VideoNode = memo((props: NodeProps) => { const { id, data } = props; + const isSelected = props.selected; const nodeData = data as unknown as VideoNodeData; const { updateNodeData } = useReactFlow(); const videoRef = useRef(null); @@ -47,6 +56,20 @@ export const VideoNode = memo((props: NodeProps) => { setIsPlaying(false); }; + const handleSave = async () => { + if (nodeData.videoUrl == null) { + toast.error(_(msg`No video to save`)); + return; + } + try { + await DownloadAssetFile(nodeData.videoUrl.split("/").pop() || ""); + toast.success(_(msg`Asset saved`)); + } catch (err) { + console.error("Failed to save asset:", err); + toast.error(_(msg`Failed to save`)); + } + }; + return ( { minWidth={200} minHeight={200} > + + + + + +
{nodeData.processing ? (
@@ -71,7 +112,9 @@ export const VideoNode = memo((props: NodeProps) => { onEnded={onVideoEnded} onClick={togglePlay} /> -
+
) : ( -
No video yet
+
+ No video yet +
)}
diff --git a/frontend/wailsjs/go/database/Service.d.ts b/frontend/wailsjs/go/database/Service.d.ts index 3ee3db4..bcaea97 100755 --- a/frontend/wailsjs/go/database/Service.d.ts +++ b/frontend/wailsjs/go/database/Service.d.ts @@ -10,6 +10,8 @@ export function DeleteModelProvider(arg1:number):Promise; export function DeleteProject(arg1:number):Promise; +export function DownloadAssetFile(arg1:string):Promise; + export function GetModelProvider(arg1:number):Promise; export function GetProject(arg1:number):Promise; diff --git a/frontend/wailsjs/go/database/Service.js b/frontend/wailsjs/go/database/Service.js index 022db04..fc9acda 100755 --- a/frontend/wailsjs/go/database/Service.js +++ b/frontend/wailsjs/go/database/Service.js @@ -18,6 +18,10 @@ export function DeleteProject(arg1) { return window['go']['database']['Service']['DeleteProject'](arg1); } +export function DownloadAssetFile(arg1) { + return window['go']['database']['Service']['DownloadAssetFile'](arg1); +} + export function GetModelProvider(arg1) { return window['go']['database']['Service']['GetModelProvider'](arg1); } diff --git a/main.go b/main.go index 4f9a8df..7e0724b 100644 --- a/main.go +++ b/main.go @@ -25,8 +25,6 @@ var assets embed.FS //go:embed wails.json var wailsJSON string -var WailsContext *context.Context - func main() { // Initialize database if err := database.InitDB(); err != nil { @@ -66,7 +64,7 @@ func main() { }, HideWindowOnClose: true, OnStartup: func(ctx context.Context) { - WailsContext = &ctx + bindingApp.WailsContext = &ctx }, SingleInstanceLock: &options.SingleInstanceLock{ UniqueId: "3e347bce-745e-4dd3-a6de-c6e6e2a44c86", @@ -74,9 +72,9 @@ func main() { secondInstanceArgs := secondInstanceData.Args println("user opened second instance", strings.Join(secondInstanceData.Args, ",")) println("user opened second from", secondInstanceData.WorkingDirectory) - runtime.WindowUnminimise(*WailsContext) - runtime.Show(*WailsContext) - go runtime.EventsEmit(*WailsContext, "launchArgs", secondInstanceArgs) + runtime.WindowUnminimise(*bindingApp.WailsContext) + runtime.Show(*bindingApp.WailsContext) + go runtime.EventsEmit(*bindingApp.WailsContext, "launchArgs", secondInstanceArgs) }, }, Mac: &mac.Options{