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:
ProgrammerIn-wonderland
2026-05-11 20:55:45 -04:00
committed by GitHub
parent f80016e4e6
commit 5e8c66142d
14 changed files with 1341 additions and 13 deletions
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -68,6 +68,7 @@ export interface IGenerateVideoParams {
metadata?: object;
input_reference?: unknown;
no_extra_params?: boolean;
puter_output_path?: string;
}
export interface IVideoProvider {
+13
View File
@@ -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 elements `src` points at a data URL containing the image.
+13
View File
@@ -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.
+2 -2
View File
@@ -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
+10
View File
@@ -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,
+133 -7
View File
@@ -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();
});
+245
View File
@@ -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);
}
}
},
];
+239
View File
@@ -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);
}
}
},
];
+2
View File
@@ -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 {