From 5e8c66142d54ffa80c3ca45c76f9ff83a860ad35 Mon Sep 17 00:00:00 2001 From: ProgrammerIn-wonderland <30693865+ProgrammerIn-wonderland@users.noreply.github.com> Date: Mon, 11 May 2026 20:55:45 -0400 Subject: [PATCH] ai image video fs output (#3079) * Add puter_output_path * Fix error reporting in puterjs. Add precheck for puterOutputPath * add tests * add tests * frontend puterjs tests --- .../ai-image/ImageGenerationDriver.test.ts | 204 +++++++++++++++ .../drivers/ai-image/ImageGenerationDriver.ts | 135 +++++++++- src/backend/drivers/ai-image/types.ts | 1 + .../ai-video/VideoGenerationDriver.test.ts | 183 +++++++++++++ .../drivers/ai-video/VideoGenerationDriver.ts | 164 +++++++++++- src/backend/drivers/ai-video/types.ts | 1 + src/docs/src/AI/txt2img.md | 13 + src/docs/src/AI/txt2vid.md | 13 + src/puter-js/src/lib/utils.js | 4 +- src/puter-js/src/modules/AI.js | 10 + src/puter-js/test/index.html | 140 +++++++++- src/puter-js/test/txt2img.test.js | 245 ++++++++++++++++++ src/puter-js/test/txt2vid.test.js | 239 +++++++++++++++++ src/puter-js/types/modules/ai.d.ts | 2 + 14 files changed, 1341 insertions(+), 13 deletions(-) create mode 100644 src/puter-js/test/txt2img.test.js create mode 100644 src/puter-js/test/txt2vid.test.js diff --git a/src/backend/drivers/ai-image/ImageGenerationDriver.test.ts b/src/backend/drivers/ai-image/ImageGenerationDriver.test.ts index 14a3a4971..d5f7555d4 100644 --- a/src/backend/drivers/ai-image/ImageGenerationDriver.test.ts +++ b/src/backend/drivers/ai-image/ImageGenerationDriver.test.ts @@ -410,6 +410,210 @@ describe('ImageGenerationDriver.generate audit log', () => { }); }); +// ── puter_output_path ───────────────────────────────────────────── + +describe('ImageGenerationDriver.generate puter_output_path', () => { + const TEST_ACTOR: import('../../core/actor.js').Actor = { + user: { uuid: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', id: 42, username: 'testuser' }, + }; + + const withTestUser = (fn: () => T | Promise): Promise => + Promise.resolve(runWithContext({ actor: TEST_ACTOR }, fn)); + + it('throws 400 when puter_output_path resolves to root', async () => { + await expect( + withTestUser(() => + driver.generate({ + model: 'dall-e-2', + prompt: 'hi', + puter_output_path: '/', + } as never), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + + expect(openaiImagesGenerateMock).not.toHaveBeenCalled(); + }); + + it('throws 400 when puter_output_path parent is root (e.g. /image.png)', async () => { + await expect( + withTestUser(() => + driver.generate({ + model: 'dall-e-2', + prompt: 'hi', + puter_output_path: '/image.png', + } as never), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + + expect(openaiImagesGenerateMock).not.toHaveBeenCalled(); + }); + + it('throws 403 when ACL denies write access to the destination', async () => { + const aclCheckSpy = vi.spyOn(server.services.acl, 'check'); + aclCheckSpy.mockResolvedValueOnce(false); + + await expect( + withTestUser(() => + driver.generate({ + model: 'dall-e-2', + prompt: 'hi', + puter_output_path: '/testuser/somedir/image.png', + } as never), + ), + ).rejects.toMatchObject({ statusCode: 403 }); + + expect(openaiImagesGenerateMock).not.toHaveBeenCalled(); + }); + + it('ACL check runs BEFORE provider.generate so credits are not wasted on a denied path', async () => { + const callOrder: string[] = []; + const aclCheckSpy = vi.spyOn(server.services.acl, 'check'); + aclCheckSpy.mockImplementation(async () => { + callOrder.push('acl'); + return false; + }); + openaiImagesGenerateMock.mockImplementation(async () => { + callOrder.push('provider'); + return { data: [{ url: 'https://oai/img.png' }] }; + }); + + await expect( + withTestUser(() => + driver.generate({ + model: 'dall-e-2', + prompt: 'hi', + puter_output_path: '/testuser/dir/img.png', + } as never), + ), + ).rejects.toMatchObject({ statusCode: 403 }); + + expect(callOrder).toEqual(['acl']); + }); + + it('resolves ~ in puter_output_path to //', async () => { + const aclCheckSpy = vi.spyOn(server.services.acl, 'check'); + aclCheckSpy.mockResolvedValueOnce(true); + + const fsWriteSpy = vi.spyOn(server.services.fs, 'write'); + fsWriteSpy.mockResolvedValueOnce(undefined as never); + + openaiImagesGenerateMock.mockResolvedValueOnce({ + data: [{ url: 'https://oai/img.png' }], + }); + fetchSpy.mockResolvedValueOnce( + new Response(Buffer.from('fake-png'), { + status: 200, + headers: { 'content-type': 'image/png' }, + }), + ); + + await withTestUser(() => + driver.generate({ + model: 'dall-e-2', + prompt: 'hi', + puter_output_path: '~/images/out.png', + } as never), + ); + + expect(fsWriteSpy).toHaveBeenCalledTimes(1); + const [, writeArg] = fsWriteSpy.mock.calls[0]!; + expect( + (writeArg as { fileMetadata: { path: string } }).fileMetadata.path, + ).toBe('/testuser/images/out.png'); + }); + + it('writes the generated image to FS and still returns the result URL', async () => { + const aclCheckSpy = vi.spyOn(server.services.acl, 'check'); + aclCheckSpy.mockResolvedValueOnce(true); + + const fsWriteSpy = vi.spyOn(server.services.fs, 'write'); + fsWriteSpy.mockResolvedValueOnce(undefined as never); + + openaiImagesGenerateMock.mockResolvedValueOnce({ + data: [{ url: 'https://oai/img.png' }], + }); + fetchSpy.mockResolvedValueOnce( + new Response(Buffer.from('fake-png'), { + status: 200, + headers: { 'content-type': 'image/png' }, + }), + ); + + const result = await withTestUser(() => + driver.generate({ + model: 'dall-e-2', + prompt: 'hi', + puter_output_path: '/testuser/photos/out.png', + } as never), + ); + + expect(result).toBe('https://oai/img.png'); + expect(fsWriteSpy).toHaveBeenCalledTimes(1); + const [userId, writeArg] = fsWriteSpy.mock.calls[0]!; + expect(userId).toBe(42); + const meta = ( + writeArg as { + fileMetadata: { + path: string; + contentType: string; + overwrite: boolean; + }; + } + ).fileMetadata; + expect(meta.path).toBe('/testuser/photos/out.png'); + expect(meta.contentType).toBe('image/png'); + expect(meta.overwrite).toBe(true); + }); + + it('does not forward puter_output_path to the upstream provider call', async () => { + const aclCheckSpy = vi.spyOn(server.services.acl, 'check'); + aclCheckSpy.mockResolvedValueOnce(true); + + const fsWriteSpy = vi.spyOn(server.services.fs, 'write'); + fsWriteSpy.mockResolvedValueOnce(undefined as never); + + openaiImagesGenerateMock.mockResolvedValueOnce({ + data: [{ url: 'https://oai/img.png' }], + }); + fetchSpy.mockResolvedValueOnce( + new Response(Buffer.from('fake-png'), { + status: 200, + headers: { 'content-type': 'image/png' }, + }), + ); + + await withTestUser(() => + driver.generate({ + model: 'dall-e-2', + prompt: 'hi', + puter_output_path: '/testuser/dir/img.png', + } as never), + ); + + const sent = openaiImagesGenerateMock.mock.calls[0]![0]; + expect(sent.puter_output_path).toBeUndefined(); + }); + + it('throws 400 when actor has no user ID but puter_output_path is set', async () => { + const noIdActor: import('../../core/actor.js').Actor = { + user: { uuid: 'f0e1d2c3-b4a5-4968-8777-0a1b2c3d4e5f', username: 'noone' }, + }; + await expect( + Promise.resolve( + runWithContext({ actor: noIdActor }, () => + driver.generate({ + model: 'dall-e-2', + prompt: 'hi', + puter_output_path: '/noone/dir/img.png', + } as never), + ), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + + expect(openaiImagesGenerateMock).not.toHaveBeenCalled(); + }); +}); + // Avoid coupling the 'unused' XAI export to lint. The catalog reference // is also used implicitly by the routing tests above. void XAI_IMAGE_GENERATION_MODELS; diff --git a/src/backend/drivers/ai-image/ImageGenerationDriver.ts b/src/backend/drivers/ai-image/ImageGenerationDriver.ts index 2ae6aa56d..20bd9166a 100644 --- a/src/backend/drivers/ai-image/ImageGenerationDriver.ts +++ b/src/backend/drivers/ai-image/ImageGenerationDriver.ts @@ -18,8 +18,11 @@ */ import crypto from 'node:crypto'; +import { posix as pathPosix } from 'node:path'; +import { Readable } from 'node:stream'; import { Context } from '../../core/context.js'; import { HttpError } from '../../core/http/HttpError.js'; +import type { Actor } from '../../core/actor.js'; import { PuterDriver } from '../types.js'; import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js'; import { CloudflareImageProvider } from './providers/cloudflare/CloudflareImageProvider.js'; @@ -114,12 +117,34 @@ export class ImageGenerationDriver extends PuterDriver { } async generate(args: IGenerateParams): Promise { - const actor = Context.get('actor'); + const actor = Context.get('actor') as Actor | undefined; if (!actor) throw new HttpError(401, 'Authentication required', { legacyCode: 'unauthorized', }); + const puterOutputPath = args.puter_output_path; + delete args.puter_output_path; + + // Validate the output path early — before spending credits. + let resolvedOutputPath: string | undefined; + if (puterOutputPath) { + const username = actor.user?.username; + const userId = actor.user?.id; + if (!userId || !username) { + throw new HttpError( + 400, + 'User ID required for puter_output_path', + { legacyCode: 'bad_request' }, + ); + } + resolvedOutputPath = this.#resolveOutputPath( + puterOutputPath, + username, + ); + await this.#assertWriteAccess(actor, resolvedOutputPath); + } + let modelId = args.model?.trim().toLowerCase(); let intendedProvider = args.provider ?? (Context.get('driverName') as string | undefined); @@ -172,11 +197,17 @@ export class ImageGenerationDriver extends PuterDriver { {}, ); - return provider.generate({ + const result = await provider.generate({ ...args, model: model.id, provider: model.provider, }); + + if (resolvedOutputPath) { + await this.#saveToFS(actor, result, resolvedOutputPath); + } + + return result; } #normalizeRatio(parameters: IGenerateParams) { @@ -340,6 +371,106 @@ export class ImageGenerationDriver extends PuterDriver { } } + async #saveToFS( + actor: Actor, + result: string, + resolvedPath: string, + ): Promise { + const userId = actor.user!.id!; + + let buffer: Buffer; + let contentType: string; + + if (result.startsWith('data:')) { + const commaIdx = result.indexOf(','); + const header = result.substring(0, commaIdx); + contentType = + header.match(/data:(.*?);/)?.[1] ?? 'application/octet-stream'; + buffer = Buffer.from(result.substring(commaIdx + 1), 'base64'); + } else { + const response = await fetch(result); + if (!response.ok) { + throw new HttpError( + 502, + `Failed to fetch generated image for FS write: ${response.status}`, + { legacyCode: 'internal_error' }, + ); + } + contentType = + response.headers.get('content-type') ?? + 'application/octet-stream'; + buffer = Buffer.from(await response.arrayBuffer()); + } + + await this.services.fs.write(userId, { + fileMetadata: { + path: resolvedPath, + size: buffer.length, + contentType, + overwrite: true, + createMissingParents: true, + }, + fileContent: Readable.from(buffer), + }); + } + + #resolveOutputPath(outputPath: string, username: string): string { + let resolved = outputPath.trim(); + if (resolved === '~' || resolved.startsWith('~/')) { + resolved = `/${username}${resolved.slice(1)}`; + } + resolved = pathPosix.normalize(resolved); + if (!resolved.startsWith('/')) { + resolved = `/${resolved}`; + } + if (resolved.length > 1 && resolved.endsWith('/')) { + resolved = resolved.slice(0, -1); + } + return resolved; + } + + async #assertWriteAccess( + actor: Actor, + resolvedPath: string, + ): Promise { + if (resolvedPath === '/') { + throw new HttpError(400, 'Cannot write to root path', { + legacyCode: 'cannot_write_to_root', + }); + } + const parentPath = pathPosix.dirname(resolvedPath); + if (parentPath === '/') { + throw new HttpError(400, 'Cannot write to root path', { + legacyCode: 'cannot_write_to_root', + }); + } + + const pathToCheck = parentPath; + const fsService = this.services.fs; + let ancestorsCache: Promise< + Array<{ uid: string; path: string }> + > | null = null; + const canWrite = await this.services.acl.check( + actor, + { + path: pathToCheck, + resolveAncestors() { + if (!ancestorsCache) { + ancestorsCache = + fsService.getAncestorChain(pathToCheck); + } + return ancestorsCache; + }, + }, + 'write', + ); + if (!canWrite) { + throw new HttpError(403, 'Write access denied for destination', { + legacyCode: 'access_denied', + }); + } + } + #resolveModel(modelId: string, provider?: string): IImageModel | null { const models = this.#modelIdMap[modelId]; if (!models || models.length === 0) return null; diff --git a/src/backend/drivers/ai-image/types.ts b/src/backend/drivers/ai-image/types.ts index e7fc0cd26..81d41be47 100644 --- a/src/backend/drivers/ai-image/types.ts +++ b/src/backend/drivers/ai-image/types.ts @@ -63,6 +63,7 @@ export interface IGenerateParams { input_image?: string; input_image_mime_type?: string; input_images?: string[]; + puter_output_path?: string; [key: string]: unknown; } diff --git a/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts b/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts index d8903ff79..8511e51d2 100644 --- a/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts +++ b/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts @@ -448,3 +448,186 @@ describe('VideoGenerationDriver metering propagation', () => { expect(usageType).toMatch(/^openai:sora-2:/); }); }); + +// ── puter_output_path ───────────────────────────────────────────── + +describe('VideoGenerationDriver.generate puter_output_path', () => { + const TEST_ACTOR: import('../../core/actor.js').Actor = { + user: { uuid: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', id: 42, username: 'testuser' }, + }; + + const withTestUser = (fn: () => T | Promise): Promise => + Promise.resolve(runWithContext({ actor: TEST_ACTOR }, fn)); + + it('throws 400 when puter_output_path is root', async () => { + await expect( + withTestUser(() => + driver.generate({ + prompt: 'hi', + model: 'sora-2', + puter_output_path: '/', + } as never), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + + expect(openaiVideosCreateMock).not.toHaveBeenCalled(); + }); + + it('throws 400 when puter_output_path parent is root (e.g. /video.mp4)', async () => { + await expect( + withTestUser(() => + driver.generate({ + prompt: 'hi', + model: 'sora-2', + puter_output_path: '/video.mp4', + } as never), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + + expect(openaiVideosCreateMock).not.toHaveBeenCalled(); + }); + + it('throws 403 when ACL denies write access', async () => { + const aclCheckSpy = vi.spyOn(server.services.acl, 'check'); + aclCheckSpy.mockResolvedValueOnce(false); + + await expect( + withTestUser(() => + driver.generate({ + prompt: 'hi', + model: 'sora-2', + puter_output_path: '/testuser/videos/clip.mp4', + } as never), + ), + ).rejects.toMatchObject({ statusCode: 403 }); + + expect(openaiVideosCreateMock).not.toHaveBeenCalled(); + }); + + it('ACL check runs BEFORE provider.generate so credits are not wasted', async () => { + const callOrder: string[] = []; + const aclCheckSpy = vi.spyOn(server.services.acl, 'check'); + aclCheckSpy.mockImplementation(async () => { + callOrder.push('acl'); + return false; + }); + openaiVideosCreateMock.mockImplementation(async () => { + callOrder.push('provider'); + return openaiCompletedJob(); + }); + + await expect( + withTestUser(() => + driver.generate({ + prompt: 'hi', + model: 'sora-2', + puter_output_path: '/testuser/dir/clip.mp4', + } as never), + ), + ).rejects.toMatchObject({ statusCode: 403 }); + + expect(callOrder).toEqual(['acl']); + }); + + it('resolves ~ in puter_output_path to //', async () => { + const aclCheckSpy = vi.spyOn(server.services.acl, 'check'); + aclCheckSpy.mockResolvedValueOnce(true); + + const fsWriteSpy = vi.spyOn(server.services.fs, 'write'); + fsWriteSpy.mockResolvedValueOnce(undefined as never); + + openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob()); + openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload()); + + await withTestUser(() => + driver.generate({ + prompt: 'hi', + model: 'sora-2', + puter_output_path: '~/videos/clip.mp4', + } as never), + ); + + expect(fsWriteSpy).toHaveBeenCalledTimes(1); + const [, writeArg] = fsWriteSpy.mock.calls[0]!; + expect( + (writeArg as { fileMetadata: { path: string } }).fileMetadata.path, + ).toBe('/testuser/videos/clip.mp4'); + }); + + it('writes stream result to FS and returns a new stream to caller', async () => { + const aclCheckSpy = vi.spyOn(server.services.acl, 'check'); + aclCheckSpy.mockResolvedValueOnce(true); + + const fsWriteSpy = vi.spyOn(server.services.fs, 'write'); + fsWriteSpy.mockResolvedValueOnce(undefined as never); + + openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob()); + openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload()); + + const result = await withTestUser(() => + driver.generate({ + prompt: 'hi', + model: 'sora-2', + puter_output_path: '/testuser/videos/clip.mp4', + } as never), + ); + + expect(fsWriteSpy).toHaveBeenCalledTimes(1); + const [userId, writeArg] = fsWriteSpy.mock.calls[0]!; + expect(userId).toBe(42); + const meta = ( + writeArg as { + fileMetadata: { + path: string; + contentType: string; + overwrite: boolean; + }; + } + ).fileMetadata; + expect(meta.path).toBe('/testuser/videos/clip.mp4'); + expect(meta.overwrite).toBe(true); + + expect(result).toBeDefined(); + }); + + it('does not forward puter_output_path to the upstream provider call', async () => { + const aclCheckSpy = vi.spyOn(server.services.acl, 'check'); + aclCheckSpy.mockResolvedValueOnce(true); + + const fsWriteSpy = vi.spyOn(server.services.fs, 'write'); + fsWriteSpy.mockResolvedValueOnce(undefined as never); + + openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob()); + openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload()); + + await withTestUser(() => + driver.generate({ + prompt: 'hi', + model: 'sora-2', + puter_output_path: '/testuser/dir/clip.mp4', + } as never), + ); + + const sent = openaiVideosCreateMock.mock.calls[0]![0]; + expect(sent.puter_output_path).toBeUndefined(); + }); + + it('throws 400 when actor has no user ID but puter_output_path is set', async () => { + const noIdActor: import('../../core/actor.js').Actor = { + user: { uuid: 'f0e1d2c3-b4a5-4968-8777-0a1b2c3d4e5f', username: 'noone' }, + }; + await expect( + Promise.resolve( + runWithContext({ actor: noIdActor }, () => + driver.generate({ + prompt: 'hi', + model: 'sora-2', + puter_output_path: '/noone/dir/clip.mp4', + } as never), + ), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + + expect(openaiVideosCreateMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/backend/drivers/ai-video/VideoGenerationDriver.ts b/src/backend/drivers/ai-video/VideoGenerationDriver.ts index 5fac58e35..1219f921f 100644 --- a/src/backend/drivers/ai-video/VideoGenerationDriver.ts +++ b/src/backend/drivers/ai-video/VideoGenerationDriver.ts @@ -17,8 +17,11 @@ * along with this program. If not, see . */ +import { posix as pathPosix } from 'node:path'; +import { Readable } from 'node:stream'; import { Context } from '../../core/context.js'; import { HttpError } from '../../core/http/HttpError.js'; +import type { Actor } from '../../core/actor.js'; import { PuterDriver } from '../types.js'; import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js'; import { GeminiVideoProvider } from './providers/gemini/GeminiVideoProvider.js'; @@ -114,12 +117,34 @@ export class VideoGenerationDriver extends PuterDriver { } async generate(args: IGenerateVideoParams) { - const actor = Context.get('actor'); + const actor = Context.get('actor') as Actor | undefined; if (!actor) throw new HttpError(401, 'Authentication required', { legacyCode: 'unauthorized', }); + const puterOutputPath = args.puter_output_path; + delete args.puter_output_path; + + // Validate the output path early — before spending credits. + let resolvedOutputPath: string | undefined; + if (puterOutputPath) { + const username = actor.user?.username; + const userId = actor.user?.id; + if (!userId || !username) { + throw new HttpError( + 400, + 'User ID required for puter_output_path', + { legacyCode: 'bad_request' }, + ); + } + resolvedOutputPath = this.#resolveOutputPath( + puterOutputPath, + username, + ); + await this.#assertWriteAccess(actor, resolvedOutputPath); + } + if (args.model) { args.model = args.model.trim().toLowerCase(); } @@ -202,11 +227,17 @@ export class VideoGenerationDriver extends PuterDriver { args.resolution = normalizedResolution; } - return await provider.generate({ + const result = await provider.generate({ ...args, model: model.id, provider: model.provider, }); + + if (resolvedOutputPath) { + return await this.#saveToFS(actor, result, resolvedOutputPath); + } + + return result; } // -- Provider registration ----------------------------------------------- @@ -350,6 +381,135 @@ export class VideoGenerationDriver extends PuterDriver { } } + async #saveToFS( + actor: Actor, + result: unknown, + resolvedPath: string, + ): Promise { + const userId = actor.user!.id!; + + let buffer: Buffer; + let contentType: string; + + if (typeof result === 'string') { + if (result.startsWith('data:')) { + const commaIdx = result.indexOf(','); + const header = result.substring(0, commaIdx); + contentType = header.match(/data:(.*?);/)?.[1] ?? 'video/mp4'; + buffer = Buffer.from(result.substring(commaIdx + 1), 'base64'); + } else { + const response = await fetch(result); + if (!response.ok) { + throw new HttpError( + 502, + `Failed to fetch generated video for FS write: ${response.status}`, + { legacyCode: 'internal_error' }, + ); + } + contentType = + response.headers.get('content-type') ?? 'video/mp4'; + buffer = Buffer.from(await response.arrayBuffer()); + } + } else if (result && typeof result === 'object' && 'stream' in result) { + const streamResult = result as { + stream: Readable; + content_type: string; + }; + contentType = streamResult.content_type || 'video/mp4'; + const chunks: Buffer[] = []; + for await (const chunk of streamResult.stream) { + chunks.push( + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), + ); + } + buffer = Buffer.concat(chunks); + } else { + throw new HttpError( + 500, + 'Unsupported video result format for puter_output_path', + { legacyCode: 'internal_error' }, + ); + } + + await this.services.fs.write(userId, { + fileMetadata: { + path: resolvedPath, + size: buffer.length, + contentType, + overwrite: true, + createMissingParents: true, + }, + fileContent: Readable.from(buffer), + }); + + // For stream results, reconstruct a new stream from the buffered data + if (typeof result !== 'string') { + return { + stream: Readable.from(buffer), + content_type: contentType, + }; + } + + return result; + } + + #resolveOutputPath(outputPath: string, username: string): string { + let resolved = outputPath.trim(); + if (resolved === '~' || resolved.startsWith('~/')) { + resolved = `/${username}${resolved.slice(1)}`; + } + resolved = pathPosix.normalize(resolved); + if (!resolved.startsWith('/')) { + resolved = `/${resolved}`; + } + if (resolved.length > 1 && resolved.endsWith('/')) { + resolved = resolved.slice(0, -1); + } + return resolved; + } + + async #assertWriteAccess( + actor: Actor, + resolvedPath: string, + ): Promise { + if (resolvedPath === '/') { + throw new HttpError(400, 'Cannot write to root path', { + legacyCode: 'cannot_write_to_root', + }); + } + const parentPath = pathPosix.dirname(resolvedPath); + if (parentPath === '/') { + throw new HttpError(400, 'Cannot write to root path', { + legacyCode: 'cannot_write_to_root', + }); + } + + const pathToCheck = parentPath; + const fsService = this.services.fs; + let ancestorsCache: Promise< + Array<{ uid: string; path: string }> + > | null = null; + const canWrite = await this.services.acl.check( + actor, + { + path: pathToCheck, + resolveAncestors() { + if (!ancestorsCache) { + ancestorsCache = + fsService.getAncestorChain(pathToCheck); + } + return ancestorsCache; + }, + }, + 'write', + ); + if (!canWrite) { + throw new HttpError(403, 'Write access denied for destination', { + legacyCode: 'access_denied', + }); + } + } + #resolveModel(modelId: string, provider?: string): IVideoModel | null { const models = this.#modelIdMap[modelId?.trim().toLowerCase()]; if (!models || models.length === 0) return null; diff --git a/src/backend/drivers/ai-video/types.ts b/src/backend/drivers/ai-video/types.ts index a7900458a..66c52be64 100644 --- a/src/backend/drivers/ai-video/types.ts +++ b/src/backend/drivers/ai-video/types.ts @@ -68,6 +68,7 @@ export interface IGenerateVideoParams { metadata?: object; input_reference?: unknown; no_extra_params?: boolean; + puter_output_path?: string; } export interface IVideoProvider { diff --git a/src/docs/src/AI/txt2img.md b/src/docs/src/AI/txt2img.md index 704141a8e..23e1b9dd1 100755 --- a/src/docs/src/AI/txt2img.md +++ b/src/docs/src/AI/txt2img.md @@ -34,6 +34,7 @@ Additional settings for the generation request. Available options depend on the | `provider` | `String` | The AI provider to use. `'openai-image-generation' (default) \| 'gemini' \| 'together' \| 'xai' \| 'replicate-image-generation'` | | `model` | `String` | Image model to use (provider-specific). Defaults to `'gpt-image-1-mini'` (OpenAI) or `'grok-2-image'` when `provider: 'xai'` | | `test_mode` | `Boolean` | When `true`, returns a sample image without using credits | +| `puter_output_path` | `String` | When set, the generated image is automatically saved to this path on the Puter filesystem. Relative paths are resolved against the app's data directory (or `~/` outside an app). The caller must have write permission to the destination | #### OpenAI Options @@ -129,6 +130,18 @@ For more details, see the [Replicate API reference](https://replicate.com/docs) Any properties not set fall back to provider defaults. +#### Saving to Puter filesystem + +Pass `puter_output_path` to persist the generated image directly on the Puter filesystem. Relative paths are resolved against `~/AppData//` when called from an app, or `~/` otherwise: + +```js +puter.ai.txt2img("A sunset over the mountains", { + puter_output_path: "images/sunset.png" // saved to ~/AppData//images/sunset.png +}); +``` + +Absolute paths (`/username/Pictures/sunset.png`) and home-relative paths (`~/Pictures/sunset.png`) are sent as-is. Write permission to the destination is enforced server-side. + ## Return value A `Promise` that resolves to an `HTMLImageElement`. The element’s `src` points at a data URL containing the image. diff --git a/src/docs/src/AI/txt2vid.md b/src/docs/src/AI/txt2vid.md index f5db05943..97b11153b 100644 --- a/src/docs/src/AI/txt2vid.md +++ b/src/docs/src/AI/txt2vid.md @@ -34,6 +34,7 @@ Additional settings for the generation request. Available options depend on the | `model` | `String` | Video model to use (provider-specific). Defaults to `'sora-2'` | | `seconds` | `Number` | Target clip length in seconds | | `test_mode` | `Boolean` | When `true`, returns a sample video without using credits | +| `puter_output_path` | `String` | When set, the generated video is automatically saved to this path on the Puter filesystem. Relative paths are resolved against the app's data directory (or `~/` outside an app). The caller must have write permission to the destination | #### OpenAI Options @@ -87,6 +88,18 @@ For more details about each option, see the [TogetherAI API reference](https://d Any properties not set fall back to provider defaults. +#### Saving to Puter filesystem + +Pass `puter_output_path` to persist the generated video directly on the Puter filesystem. Relative paths are resolved against `~/AppData//` when called from an app, or `~/` otherwise: + +```js +puter.ai.txt2vid("A drone shot over a forest", { + puter_output_path: "videos/forest.mp4" // saved to ~/AppData//videos/forest.mp4 +}); +``` + +Absolute paths (`/username/Videos/forest.mp4`) and home-relative paths (`~/Videos/forest.mp4`) are sent as-is. Write permission to the destination is enforced server-side. + ## Return value A `Promise` that resolves to an `HTMLVideoElement`. The element is preloaded, has `controls` enabled, and exposes metadata via `data-mime-type` and `data-source` attributes. Append it to the DOM to display the generated clip immediately. diff --git a/src/puter-js/src/lib/utils.js b/src/puter-js/src/lib/utils.js index a1de6cda6..8bfdfbf73 100644 --- a/src/puter-js/src/lib/utils.js +++ b/src/puter-js/src/lib/utils.js @@ -521,7 +521,7 @@ async function driverCall_ ( } // HTTP Error - unauthorized - if ( response.status === 401 || resp?.code === 'token_auth_failed' ) { + if ( response.target.status === 401 || resp?.code === 'token_auth_failed' ) { if ( resp?.code === 'token_auth_failed' && puter.env === 'web' ) { try { puter.resetAuthToken(); @@ -543,7 +543,7 @@ async function driverCall_ ( return reject_func({ status: 401, message: 'Unauthorized' }); } // HTTP Error - other - else if ( response.status && response.status !== 200 ) { + else if ( response.target.status && response.target.status !== 200 ) { // if error callback is provided, call it error_cb(resp); // reject promise diff --git a/src/puter-js/src/modules/AI.js b/src/puter-js/src/modules/AI.js index 88614e346..c1205ea2c 100644 --- a/src/puter-js/src/modules/AI.js +++ b/src/puter-js/src/modules/AI.js @@ -1,4 +1,5 @@ import * as utils from '../lib/utils.js'; +import getAbsolutePathForApp from './FileSystem/utils/getAbsolutePathForApp.js'; const normalizeTTSProvider = (value) => { if ( typeof value !== 'string' ) { @@ -910,6 +911,11 @@ class AI { } else { AIService = 'ai-image'; } + + if ( options.puter_output_path ) { + options.puter_output_path = getAbsolutePathForApp(options.puter_output_path); + } + // Call the original chat.complete method return await utils.make_driver_method(['prompt'], 'puter-image-generation', AIService, 'generate', { responseType: 'blob', @@ -983,6 +989,10 @@ class AI { videoService = driverHint; } + if ( options.puter_output_path ) { + options.puter_output_path = getAbsolutePathForApp(options.puter_output_path); + } + return await utils.make_driver_method(['prompt'], 'puter-video-generation', videoService, 'generate', { responseType: 'blob', test_mode: testMode ?? false, diff --git a/src/puter-js/test/index.html b/src/puter-js/test/index.html index 45b0890fe..63fcd63b1 100644 --- a/src/puter-js/test/index.html +++ b/src/puter-js/test/index.html @@ -6,6 +6,8 @@ + +