mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-26 07:57:10 +00:00
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
This commit is contained in:
@@ -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 = <T>(fn: () => T | Promise<T>): Promise<T> =>
|
||||
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 /<username>/', 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;
|
||||
|
||||
@@ -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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = <T>(fn: () => T | Promise<T>): Promise<T> =>
|
||||
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 /<username>/', 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,11 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<unknown> {
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface IGenerateVideoParams {
|
||||
metadata?: object;
|
||||
input_reference?: unknown;
|
||||
no_extra_params?: boolean;
|
||||
puter_output_path?: string;
|
||||
}
|
||||
|
||||
export interface IVideoProvider {
|
||||
|
||||
@@ -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/<appID>/` 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/<appID>/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.
|
||||
|
||||
@@ -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/<appID>/` 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/<appID>/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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<script src="./fs.test.js"></script>
|
||||
<script src="./ai.test.js"></script>
|
||||
<script src="./txt2speech.test.js"></script>
|
||||
<script src="./txt2img.test.js"></script>
|
||||
<script src="./txt2vid.test.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
@@ -701,6 +703,36 @@
|
||||
</div>`);
|
||||
}
|
||||
|
||||
$('#tests').append('<h2><label><input type="checkbox" id="txt2imgTests-group"> Text-to-Image</label></h2>');
|
||||
for (let i = 0; i < txt2imgTests.length; i++) {
|
||||
const testInfo = getTestInfo(txt2imgTests[i]);
|
||||
$('#tests').append(`<div class="test-container" id="txt2imgTests-container-${i}">
|
||||
<div class="test-checkbox-container">
|
||||
<input type="checkbox" class="test-checkbox txt2imgTests-checkbox" id="txt2imgTests${i}">
|
||||
<label for="txt2imgTests${i}">
|
||||
<div class="test-name">${testInfo.name}</div>
|
||||
<div class="test-description">${testInfo.description}</div>
|
||||
</label><br>
|
||||
<button class="test-run-button" onclick="runSingleTest('txt2img', ${i})">Run Test</button>
|
||||
</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
$('#tests').append('<h2><label><input type="checkbox" id="txt2vidTests-group"> Text-to-Video</label></h2>');
|
||||
for (let i = 0; i < txt2vidTests.length; i++) {
|
||||
const testInfo = getTestInfo(txt2vidTests[i]);
|
||||
$('#tests').append(`<div class="test-container" id="txt2vidTests-container-${i}">
|
||||
<div class="test-checkbox-container">
|
||||
<input type="checkbox" class="test-checkbox txt2vidTests-checkbox" id="txt2vidTests${i}">
|
||||
<label for="txt2vidTests${i}">
|
||||
<div class="test-name">${testInfo.name}</div>
|
||||
<div class="test-description">${testInfo.description}</div>
|
||||
</label><br>
|
||||
<button class="test-run-button" onclick="runSingleTest('txt2vid', ${i})">Run Test</button>
|
||||
</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
// Add event listeners for group checkboxes
|
||||
$('#fsTests-group').change(function() {
|
||||
const isChecked = $(this).prop('checked');
|
||||
@@ -722,6 +754,16 @@
|
||||
$('.txt2speechTests-checkbox').prop('checked', isChecked);
|
||||
});
|
||||
|
||||
$('#txt2imgTests-group').change(function() {
|
||||
const isChecked = $(this).prop('checked');
|
||||
$('.txt2imgTests-checkbox').prop('checked', isChecked);
|
||||
});
|
||||
|
||||
$('#txt2vidTests-group').change(function() {
|
||||
const isChecked = $(this).prop('checked');
|
||||
$('.txt2vidTests-checkbox').prop('checked', isChecked);
|
||||
});
|
||||
|
||||
// Add event listeners for individual checkboxes to update group checkbox state
|
||||
$(document).on('change', '.fsTests-checkbox', function() {
|
||||
const totalFsTests = $('.fsTests-checkbox').length;
|
||||
@@ -765,7 +807,7 @@
|
||||
$(document).on('change', '.txt2speechTests-checkbox', function() {
|
||||
const totalTxt2speechTests = $('.txt2speechTests-checkbox').length;
|
||||
const checkedTxt2speechTests = $('.txt2speechTests-checkbox:checked').length;
|
||||
|
||||
|
||||
if (checkedTxt2speechTests === 0) {
|
||||
$('#txt2speechTests-group').prop('checked', false).prop('indeterminate', false);
|
||||
} else if (checkedTxt2speechTests === totalTxt2speechTests) {
|
||||
@@ -775,6 +817,32 @@
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('change', '.txt2imgTests-checkbox', function() {
|
||||
const totalTxt2imgTests = $('.txt2imgTests-checkbox').length;
|
||||
const checkedTxt2imgTests = $('.txt2imgTests-checkbox:checked').length;
|
||||
|
||||
if (checkedTxt2imgTests === 0) {
|
||||
$('#txt2imgTests-group').prop('checked', false).prop('indeterminate', false);
|
||||
} else if (checkedTxt2imgTests === totalTxt2imgTests) {
|
||||
$('#txt2imgTests-group').prop('checked', true).prop('indeterminate', false);
|
||||
} else {
|
||||
$('#txt2imgTests-group').prop('checked', false).prop('indeterminate', true);
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('change', '.txt2vidTests-checkbox', function() {
|
||||
const totalTxt2vidTests = $('.txt2vidTests-checkbox').length;
|
||||
const checkedTxt2vidTests = $('.txt2vidTests-checkbox:checked').length;
|
||||
|
||||
if (checkedTxt2vidTests === 0) {
|
||||
$('#txt2vidTests-group').prop('checked', false).prop('indeterminate', false);
|
||||
} else if (checkedTxt2vidTests === totalTxt2vidTests) {
|
||||
$('#txt2vidTests-group').prop('checked', true).prop('indeterminate', false);
|
||||
} else {
|
||||
$('#txt2vidTests-group').prop('checked', false).prop('indeterminate', true);
|
||||
}
|
||||
});
|
||||
|
||||
window.assert = function(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message || "Assertion failed");
|
||||
@@ -786,7 +854,9 @@
|
||||
'fs': fsTests,
|
||||
'kv': kvTests,
|
||||
'ai': aiTests,
|
||||
'txt2speech': txt2speechTests
|
||||
'txt2speech': txt2speechTests,
|
||||
'txt2img': txt2imgTests,
|
||||
'txt2vid': txt2vidTests
|
||||
};
|
||||
|
||||
const tests = testSuites[testType];
|
||||
@@ -956,15 +1026,71 @@
|
||||
$(`#txt2speechTests-container-${i}`).append(`<pre style="color:red; white-space: pre-wrap; font-size: 12px; margin: 5px 0; padding: 10px; background-color: #f8f8f8; border-radius: 3px;">${errorMessage}</pre>`);
|
||||
testProgress.failed++;
|
||||
}
|
||||
|
||||
|
||||
testProgress.completed++;
|
||||
updateProgressPanel();
|
||||
|
||||
|
||||
// Small delay to make progress visible
|
||||
await delay(100);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for (let i = 0; i < txt2imgTests.length; i++) {
|
||||
if (document.getElementById(`txt2imgTests${i}`).checked) {
|
||||
const testInfo = getTestInfo(txt2imgTests[i]);
|
||||
testProgress.currentTest = `Text-to-Image: ${testInfo.name}`;
|
||||
updateProgressPanel();
|
||||
|
||||
try{
|
||||
await executeTest(txt2imgTests[i]);
|
||||
$(`#txt2imgTests-container-${i}`).css('background-color', '#85e085');
|
||||
testProgress.passed++;
|
||||
} catch (e) {
|
||||
console.error('Txt2Img Test failed:', testInfo.name, e);
|
||||
$(`#txt2imgTests-container-${i}`).css('background-color', '#ff8484');
|
||||
let errorMessage = e.message || e.toString();
|
||||
if (e.originalError) {
|
||||
errorMessage += '\n\nOriginal Error:\n' + JSON.stringify(e.originalError, null, 2);
|
||||
}
|
||||
$(`#txt2imgTests-container-${i}`).append(`<pre style="color:red; white-space: pre-wrap; font-size: 12px; margin: 5px 0; padding: 10px; background-color: #f8f8f8; border-radius: 3px;">${errorMessage}</pre>`);
|
||||
testProgress.failed++;
|
||||
}
|
||||
|
||||
testProgress.completed++;
|
||||
updateProgressPanel();
|
||||
|
||||
await delay(100);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < txt2vidTests.length; i++) {
|
||||
if (document.getElementById(`txt2vidTests${i}`).checked) {
|
||||
const testInfo = getTestInfo(txt2vidTests[i]);
|
||||
testProgress.currentTest = `Text-to-Video: ${testInfo.name}`;
|
||||
updateProgressPanel();
|
||||
|
||||
try{
|
||||
await executeTest(txt2vidTests[i]);
|
||||
$(`#txt2vidTests-container-${i}`).css('background-color', '#85e085');
|
||||
testProgress.passed++;
|
||||
} catch (e) {
|
||||
console.error('Txt2Vid Test failed:', testInfo.name, e);
|
||||
$(`#txt2vidTests-container-${i}`).css('background-color', '#ff8484');
|
||||
let errorMessage = e.message || e.toString();
|
||||
if (e.originalError) {
|
||||
errorMessage += '\n\nOriginal Error:\n' + JSON.stringify(e.originalError, null, 2);
|
||||
}
|
||||
$(`#txt2vidTests-container-${i}`).append(`<pre style="color:red; white-space: pre-wrap; font-size: 12px; margin: 5px 0; padding: 10px; background-color: #f8f8f8; border-radius: 3px;">${errorMessage}</pre>`);
|
||||
testProgress.failed++;
|
||||
}
|
||||
|
||||
testProgress.completed++;
|
||||
updateProgressPanel();
|
||||
|
||||
await delay(100);
|
||||
}
|
||||
}
|
||||
|
||||
// Show completion message
|
||||
testProgress.currentTest = `Complete! ${testProgress.passed} passed, ${testProgress.failed} failed`;
|
||||
updateProgressPanel();
|
||||
@@ -996,7 +1122,7 @@
|
||||
$('#master-checkbox').change(function() {
|
||||
const isChecked = $(this).prop('checked');
|
||||
$('.test-checkbox').prop('checked', isChecked);
|
||||
$('#fsTests-group, #kvTests-group, #aiTests-group, #txt2speechTests-group').prop('checked', isChecked);
|
||||
$('#fsTests-group, #kvTests-group, #aiTests-group, #txt2speechTests-group, #txt2imgTests-group, #txt2vidTests-group').prop('checked', isChecked);
|
||||
// Update the counter display
|
||||
updateMasterCheckboxState();
|
||||
});
|
||||
@@ -1024,7 +1150,7 @@
|
||||
});
|
||||
|
||||
// Update master checkbox state when group checkboxes change
|
||||
$('#fsTests-group, #kvTests-group, #aiTests-group, #txt2speechTests-group').change(function() {
|
||||
$('#fsTests-group, #kvTests-group, #aiTests-group, #txt2speechTests-group, #txt2imgTests-group, #txt2vidTests-group').change(function() {
|
||||
updateMasterCheckboxState();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/* eslint-disable */
|
||||
// TODO: Make these more compatible with eslint
|
||||
|
||||
// Core test functions for txt2img functionality
|
||||
const testTxt2ImgBasicCore = async function() {
|
||||
const result = await puter.ai.txt2img("A red circle on a white background", true);
|
||||
|
||||
assert(result instanceof Image, "txt2img should return an Image object");
|
||||
assert(result !== null, "txt2img should not return null");
|
||||
|
||||
assert(typeof result.src === 'string', "result should have src property as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
const isValidUrl = result.src.startsWith('blob:') ||
|
||||
result.src.startsWith('data:') ||
|
||||
result.src.startsWith('http:') ||
|
||||
result.src.startsWith('https:');
|
||||
assert(isValidUrl, `src should be a valid URL, got: ${result.src.substring(0, 80)}`);
|
||||
|
||||
assert(typeof result.toString === 'function', "result should have toString method");
|
||||
assert(typeof result.valueOf === 'function', "result should have valueOf method");
|
||||
assert(result.toString() === result.src, "toString() should return src");
|
||||
assert(result.valueOf() === result.src, "valueOf() should return src");
|
||||
};
|
||||
|
||||
const testTxt2ImgWithOptionsCore = async function() {
|
||||
const result = await puter.ai.txt2img("A blue square", {
|
||||
test_mode: true,
|
||||
});
|
||||
|
||||
assert(result instanceof Image, "txt2img with options should return an Image object");
|
||||
assert(result !== null, "txt2img with options should not return null");
|
||||
|
||||
assert(typeof result.src === 'string', "result should have src property as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
assert(result.toString() === result.src, "toString() should return src");
|
||||
assert(result.valueOf() === result.src, "valueOf() should return src");
|
||||
};
|
||||
|
||||
const testTxt2ImgObjectSyntaxCore = async function() {
|
||||
const result = await puter.ai.txt2img({
|
||||
prompt: "A green triangle",
|
||||
test_mode: true,
|
||||
});
|
||||
|
||||
assert(result instanceof Image, "txt2img object syntax should return an Image object");
|
||||
assert(result !== null, "txt2img object syntax should not return null");
|
||||
|
||||
assert(typeof result.src === 'string', "result should have src property as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
};
|
||||
|
||||
const testTxt2ImgPuterOutputPathCore = async function() {
|
||||
const outputPath = `test_output_${Date.now()}.png`;
|
||||
|
||||
const result = await puter.ai.txt2img("A yellow star on black background", {
|
||||
test_mode: true,
|
||||
puter_output_path: outputPath,
|
||||
});
|
||||
|
||||
assert(result instanceof Image, "txt2img with puter_output_path should return an Image object");
|
||||
assert(result !== null, "txt2img with puter_output_path should not return null");
|
||||
assert(typeof result.src === 'string', "result should have src property as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
// Verify the file was written to the filesystem
|
||||
const stat = await puter.fs.stat(outputPath);
|
||||
assert(stat !== null && stat !== undefined, "file should exist at the output path");
|
||||
assert(stat.size > 0, "written file should not be empty");
|
||||
|
||||
// Clean up
|
||||
await puter.fs.delete(outputPath);
|
||||
};
|
||||
|
||||
const testTxt2ImgPuterOutputPathAbsoluteCore = async function() {
|
||||
const user = await puter.auth.getUser();
|
||||
const outputPath = `/${user.username}/test_output_abs_${Date.now()}.png`;
|
||||
|
||||
const result = await puter.ai.txt2img("A purple diamond", {
|
||||
test_mode: true,
|
||||
puter_output_path: outputPath,
|
||||
});
|
||||
|
||||
assert(result instanceof Image, "txt2img with absolute puter_output_path should return an Image");
|
||||
assert(typeof result.src === 'string', "result should have src as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
// Verify the file was written
|
||||
const stat = await puter.fs.stat(outputPath);
|
||||
assert(stat !== null && stat !== undefined, "file should exist at absolute output path");
|
||||
assert(stat.size > 0, "written file should not be empty");
|
||||
|
||||
// Clean up
|
||||
await puter.fs.delete(outputPath);
|
||||
};
|
||||
|
||||
const testTxt2ImgPuterOutputPathHomeTildeCore = async function() {
|
||||
const outputPath = `~/test_output_tilde_${Date.now()}.png`;
|
||||
|
||||
const result = await puter.ai.txt2img("An orange hexagon", {
|
||||
test_mode: true,
|
||||
puter_output_path: outputPath,
|
||||
});
|
||||
|
||||
assert(result instanceof Image, "txt2img with ~ puter_output_path should return an Image");
|
||||
assert(typeof result.src === 'string', "result should have src as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
// Verify the file was written (resolve ~ for stat)
|
||||
const user = await puter.auth.getUser();
|
||||
const resolvedPath = outputPath.replace('~', `/${user.username}`);
|
||||
const stat = await puter.fs.stat(resolvedPath);
|
||||
assert(stat !== null && stat !== undefined, "file should exist at home-relative output path");
|
||||
assert(stat.size > 0, "written file should not be empty");
|
||||
|
||||
// Clean up
|
||||
await puter.fs.delete(resolvedPath);
|
||||
};
|
||||
|
||||
const testTxt2ImgPuterOutputPathPermissionDeniedCore = async function() {
|
||||
let caught = false;
|
||||
let caughtError = null;
|
||||
try {
|
||||
await puter.ai.txt2img("A test image", {
|
||||
test_mode: true,
|
||||
puter_output_path: "/some_other_user/no_access/image.png",
|
||||
});
|
||||
} catch (error) {
|
||||
caught = true;
|
||||
caughtError = error;
|
||||
}
|
||||
|
||||
assert(caught, "txt2img should throw when writing to a path without permission");
|
||||
assert(caughtError !== null, "error should not be null");
|
||||
|
||||
// The error should contain the actual backend error, NOT the generic
|
||||
// "Unexpected image response format" message
|
||||
const errMsg = typeof caughtError === 'string' ? caughtError
|
||||
: caughtError?.error?.message ?? caughtError?.message ?? '';
|
||||
const errCode = caughtError?.error?.code ?? caughtError?.code ?? '';
|
||||
|
||||
assert(
|
||||
!errMsg.includes('Unexpected image response format'),
|
||||
`Should surface the real backend error, not the generic 'Unexpected image response format'. Got: ${errMsg}`
|
||||
);
|
||||
assert(
|
||||
errCode !== 'invalid_image_response',
|
||||
`Error code should not be 'invalid_image_response'. Got code: ${errCode}, message: ${errMsg}`
|
||||
);
|
||||
};
|
||||
|
||||
// Export test functions
|
||||
window.txt2imgTests = [
|
||||
{
|
||||
name: "testTxt2ImgBasic",
|
||||
description: "Test basic text-to-image generation with test mode and verify Image object structure",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2ImgBasicCore();
|
||||
pass("testTxt2ImgBasic passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2ImgBasic failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2ImgWithOptions",
|
||||
description: "Test txt2img with prompt string and options object",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2ImgWithOptionsCore();
|
||||
pass("testTxt2ImgWithOptions passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2ImgWithOptions failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2ImgObjectSyntax",
|
||||
description: "Test txt2img with single options object containing prompt",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2ImgObjectSyntaxCore();
|
||||
pass("testTxt2ImgObjectSyntax passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2ImgObjectSyntax failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2ImgPuterOutputPath",
|
||||
description: "Test that puter_output_path writes the generated image to the Puter filesystem (relative path)",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2ImgPuterOutputPathCore();
|
||||
pass("testTxt2ImgPuterOutputPath passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2ImgPuterOutputPath failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2ImgPuterOutputPathAbsolute",
|
||||
description: "Test puter_output_path with an absolute path",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2ImgPuterOutputPathAbsoluteCore();
|
||||
pass("testTxt2ImgPuterOutputPathAbsolute passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2ImgPuterOutputPathAbsolute failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2ImgPuterOutputPathHomeTilde",
|
||||
description: "Test puter_output_path with a ~/... home-relative path",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2ImgPuterOutputPathHomeTildeCore();
|
||||
pass("testTxt2ImgPuterOutputPathHomeTilde passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2ImgPuterOutputPathHomeTilde failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2ImgPuterOutputPathPermissionDenied",
|
||||
description: "Test that writing to a path without permission surfaces the real backend error, not a generic one",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2ImgPuterOutputPathPermissionDeniedCore();
|
||||
pass("testTxt2ImgPuterOutputPathPermissionDenied passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2ImgPuterOutputPathPermissionDenied failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,239 @@
|
||||
/* eslint-disable */
|
||||
// TODO: Make these more compatible with eslint
|
||||
|
||||
// Core test functions for txt2vid functionality
|
||||
const testTxt2VidBasicCore = async function() {
|
||||
const result = await puter.ai.txt2vid("A sunrise over the ocean", true);
|
||||
|
||||
assert(result !== null, "txt2vid should not return null");
|
||||
assert(typeof result === 'object', "txt2vid should return an object");
|
||||
|
||||
// Should be a video element (or object with video-like properties in non-DOM envs)
|
||||
assert(typeof result.src === 'string', "result should have src property as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
const isValidUrl = result.src.startsWith('blob:') ||
|
||||
result.src.startsWith('data:') ||
|
||||
result.src.startsWith('http:') ||
|
||||
result.src.startsWith('https:');
|
||||
assert(isValidUrl, `src should be a valid URL, got: ${result.src.substring(0, 80)}`);
|
||||
|
||||
assert(typeof result.toString === 'function', "result should have toString method");
|
||||
assert(typeof result.valueOf === 'function', "result should have valueOf method");
|
||||
assert(result.toString() === result.src, "toString() should return src");
|
||||
assert(result.valueOf() === result.src, "valueOf() should return src");
|
||||
};
|
||||
|
||||
const testTxt2VidWithOptionsCore = async function() {
|
||||
const result = await puter.ai.txt2vid("A cat walking through grass", {
|
||||
test_mode: true,
|
||||
});
|
||||
|
||||
assert(result !== null, "txt2vid with options should not return null");
|
||||
assert(typeof result === 'object', "txt2vid with options should return an object");
|
||||
assert(typeof result.src === 'string', "result should have src as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
assert(result.toString() === result.src, "toString() should return src");
|
||||
assert(result.valueOf() === result.src, "valueOf() should return src");
|
||||
};
|
||||
|
||||
const testTxt2VidObjectSyntaxCore = async function() {
|
||||
const result = await puter.ai.txt2vid({
|
||||
prompt: "Clouds drifting across a blue sky",
|
||||
test_mode: true,
|
||||
});
|
||||
|
||||
assert(result !== null, "txt2vid object syntax should not return null");
|
||||
assert(typeof result === 'object', "txt2vid object syntax should return an object");
|
||||
assert(typeof result.src === 'string', "result should have src as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
};
|
||||
|
||||
const testTxt2VidPuterOutputPathCore = async function() {
|
||||
const outputPath = `test_output_${Date.now()}.mp4`;
|
||||
|
||||
const result = await puter.ai.txt2vid("A ball rolling down a hill", {
|
||||
test_mode: true,
|
||||
puter_output_path: outputPath,
|
||||
});
|
||||
|
||||
assert(result !== null, "txt2vid with puter_output_path should not return null");
|
||||
assert(typeof result === 'object', "txt2vid with puter_output_path should return an object");
|
||||
assert(typeof result.src === 'string', "result should have src as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
// Verify the file was written to the filesystem
|
||||
const stat = await puter.fs.stat(outputPath);
|
||||
assert(stat !== null && stat !== undefined, "file should exist at the output path");
|
||||
assert(stat.size > 0, "written file should not be empty");
|
||||
|
||||
// Clean up
|
||||
await puter.fs.delete(outputPath);
|
||||
};
|
||||
|
||||
const testTxt2VidPuterOutputPathAbsoluteCore = async function() {
|
||||
const user = await puter.auth.getUser();
|
||||
const outputPath = `/${user.username}/test_output_abs_${Date.now()}.mp4`;
|
||||
|
||||
const result = await puter.ai.txt2vid("Rain falling on a window", {
|
||||
test_mode: true,
|
||||
puter_output_path: outputPath,
|
||||
});
|
||||
|
||||
assert(result !== null, "txt2vid with absolute puter_output_path should not return null");
|
||||
assert(typeof result.src === 'string', "result should have src as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
// Verify the file was written
|
||||
const stat = await puter.fs.stat(outputPath);
|
||||
assert(stat !== null && stat !== undefined, "file should exist at absolute output path");
|
||||
assert(stat.size > 0, "written file should not be empty");
|
||||
|
||||
// Clean up
|
||||
await puter.fs.delete(outputPath);
|
||||
};
|
||||
|
||||
const testTxt2VidPuterOutputPathHomeTildeCore = async function() {
|
||||
const outputPath = `~/test_output_tilde_${Date.now()}.mp4`;
|
||||
|
||||
const result = await puter.ai.txt2vid("Waves crashing on a beach", {
|
||||
test_mode: true,
|
||||
puter_output_path: outputPath,
|
||||
});
|
||||
|
||||
assert(result !== null, "txt2vid with ~ puter_output_path should not return null");
|
||||
assert(typeof result.src === 'string', "result should have src as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
// Verify the file was written (resolve ~ for stat)
|
||||
const user = await puter.auth.getUser();
|
||||
const resolvedPath = outputPath.replace('~', `/${user.username}`);
|
||||
const stat = await puter.fs.stat(resolvedPath);
|
||||
assert(stat !== null && stat !== undefined, "file should exist at home-relative output path");
|
||||
assert(stat.size > 0, "written file should not be empty");
|
||||
|
||||
// Clean up
|
||||
await puter.fs.delete(resolvedPath);
|
||||
};
|
||||
|
||||
const testTxt2VidPuterOutputPathPermissionDeniedCore = async function() {
|
||||
let caught = false;
|
||||
let caughtError = null;
|
||||
try {
|
||||
await puter.ai.txt2vid("A test video", {
|
||||
test_mode: true,
|
||||
puter_output_path: "/some_other_user/no_access/video.mp4",
|
||||
});
|
||||
} catch (error) {
|
||||
caught = true;
|
||||
caughtError = error;
|
||||
}
|
||||
|
||||
assert(caught, "txt2vid should throw when writing to a path without permission");
|
||||
assert(caughtError !== null, "error should not be null");
|
||||
|
||||
// The error should contain the actual backend error, NOT a generic message
|
||||
const errMsg = typeof caughtError === 'string' ? caughtError
|
||||
: caughtError?.error?.message ?? caughtError?.message ?? '';
|
||||
const errCode = caughtError?.error?.code ?? caughtError?.code ?? '';
|
||||
|
||||
assert(
|
||||
errCode !== 'invalid_video_response',
|
||||
`Error code should not be generic. Got code: ${errCode}, message: ${errMsg}`
|
||||
);
|
||||
};
|
||||
|
||||
// Export test functions
|
||||
window.txt2vidTests = [
|
||||
{
|
||||
name: "testTxt2VidBasic",
|
||||
description: "Test basic text-to-video generation with test mode and verify video element structure",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2VidBasicCore();
|
||||
pass("testTxt2VidBasic passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2VidBasic failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2VidWithOptions",
|
||||
description: "Test txt2vid with prompt string and options object",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2VidWithOptionsCore();
|
||||
pass("testTxt2VidWithOptions passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2VidWithOptions failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2VidObjectSyntax",
|
||||
description: "Test txt2vid with single options object containing prompt",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2VidObjectSyntaxCore();
|
||||
pass("testTxt2VidObjectSyntax passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2VidObjectSyntax failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2VidPuterOutputPath",
|
||||
description: "Test that puter_output_path writes the generated video to the Puter filesystem (relative path)",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2VidPuterOutputPathCore();
|
||||
pass("testTxt2VidPuterOutputPath passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2VidPuterOutputPath failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2VidPuterOutputPathAbsolute",
|
||||
description: "Test puter_output_path with an absolute path",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2VidPuterOutputPathAbsoluteCore();
|
||||
pass("testTxt2VidPuterOutputPathAbsolute passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2VidPuterOutputPathAbsolute failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2VidPuterOutputPathHomeTilde",
|
||||
description: "Test puter_output_path with a ~/... home-relative path",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2VidPuterOutputPathHomeTildeCore();
|
||||
pass("testTxt2VidPuterOutputPathHomeTilde passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2VidPuterOutputPathHomeTilde failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2VidPuterOutputPathPermissionDenied",
|
||||
description: "Test that writing to a path without permission surfaces the real backend error",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2VidPuterOutputPathPermissionDeniedCore();
|
||||
pass("testTxt2VidPuterOutputPathPermissionDenied passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2VidPuterOutputPathPermissionDenied failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
Vendored
+2
@@ -77,6 +77,7 @@ export interface Txt2ImgOptions {
|
||||
disable_safety_checker?: boolean;
|
||||
response_format?: string;
|
||||
test_mode?: boolean;
|
||||
puter_output_path?: string;
|
||||
}
|
||||
|
||||
export interface Txt2VidOptions {
|
||||
@@ -106,6 +107,7 @@ export interface Txt2VidOptions {
|
||||
reference_images?: string[];
|
||||
frame_images?: Array<{ input_image: string; frame: number }>;
|
||||
metadata?: Record<string, unknown>;
|
||||
puter_output_path?: string;
|
||||
}
|
||||
|
||||
export interface Txt2SpeechOptions {
|
||||
|
||||
Reference in New Issue
Block a user