Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
367edde
fix(studio): recover completed video jobs
faresmohamed260 Aug 25, 2026
e6a4615
feat(studio): auto-refresh library views
faresmohamed260 Aug 25, 2026
d90a4f6
refactor(studio): remove manual Jobs refresh
faresmohamed260 Aug 25, 2026
b61a557
refactor(studio): remove manual Gallery refresh
faresmohamed260 Aug 25, 2026
6855911
refactor(studio): remove manual Favorites refresh
faresmohamed260 Aug 25, 2026
0cfa8c7
test(studio): cover recovery and auto-refresh contracts
faresmohamed260 Aug 25, 2026
bdc6c8f
ci(studio): refresh approved baselines for auto-update UI
faresmohamed260 Aug 25, 2026
4c6f2a2
test(studio): refresh auto-update visual baselines
github-actions[bot] Aug 25, 2026
b66310e
chore(studio): remove temporary baseline refresh workflow
faresmohamed260 Aug 25, 2026
aa007f3
ci(studio): clean incidental visual baseline updates
faresmohamed260 Aug 25, 2026
1835b6c
test(studio): keep only intentional library baselines
github-actions[bot] Aug 25, 2026
33b7599
docs(studio): explain durable recovery persistence
faresmohamed260 Aug 25, 2026
0fccf81
fix(studio): make gallery images fill media cards
faresmohamed260 Aug 25, 2026
6b68193
test(studio): expect gallery thumbnails to fill cards
faresmohamed260 Aug 25, 2026
d594407
ci(studio): refresh gallery cover baselines
faresmohamed260 Aug 25, 2026
b82f585
ci(studio): remove gallery baseline helper
faresmohamed260 Aug 25, 2026
9fa0381
feat(studio): redesign Gallery controls for desktop and mobile
faresmohamed260 Aug 25, 2026
5f80530
style(studio): add responsive Gallery asset-manager layout
faresmohamed260 Aug 25, 2026
cd40438
style(studio): load Gallery redesign overrides
faresmohamed260 Aug 25, 2026
b446372
feat(studio): support Gallery date and favorite filters
faresmohamed260 Aug 25, 2026
45ffa26
feat(studio): filter Gallery history by date and favorite state
faresmohamed260 Aug 25, 2026
fd0e4fc
feat(studio): wire Gallery date favorites and collection controls
faresmohamed260 Aug 25, 2026
e039500
ci(studio): wire Gallery redesign into app shell
faresmohamed260 Aug 25, 2026
5ffec68
feat(studio): connect Gallery asset manager controls
github-actions[bot] Aug 25, 2026
bc04fb1
chore(studio): remove Gallery wiring helper workflow
faresmohamed260 Aug 25, 2026
11267ab
test(studio): align accessibility contract with Gallery redesign
faresmohamed260 Aug 25, 2026
feb2484
ci(studio): align Gallery visual contracts with responsive redesign
faresmohamed260 Aug 25, 2026
b6754d7
test(studio): cover responsive Gallery asset manager
github-actions[bot] Aug 25, 2026
89cc115
chore(studio): remove Gallery contract helper workflow
faresmohamed260 Aug 25, 2026
c6f95b6
ci(studio): update Gallery interaction preview contract
faresmohamed260 Aug 25, 2026
bb3e4e6
ci(studio): add temporary Gallery test patch helper
faresmohamed260 Aug 25, 2026
ff5fabb
ci(studio): run Gallery test patch helper
faresmohamed260 Aug 25, 2026
dab6a8f
test(studio): follow redesigned Gallery actions
github-actions[bot] Aug 25, 2026
71ab3f6
chore(studio): remove failed Gallery test helper workflow
faresmohamed260 Aug 25, 2026
dfe9769
chore(studio): remove temporary Gallery patch script
faresmohamed260 Aug 25, 2026
b5f5e4f
chore(studio): remove Gallery test patch workflow
faresmohamed260 Aug 25, 2026
763816f
ci(studio): finalize Gallery redesign visual baselines
faresmohamed260 Aug 25, 2026
3ab82e8
test(studio): approve responsive Gallery redesign
github-actions[bot] Aug 25, 2026
8fa8603
chore(studio): remove Gallery baseline helper workflow
faresmohamed260 Aug 25, 2026
4c392b2
feat(studio): support persistent upload assets in R2
faresmohamed260 Aug 25, 2026
16c41c2
feat(studio): add persistent uploads library API
faresmohamed260 Aug 25, 2026
ee59c62
db(studio): add persistent upload asset library
faresmohamed260 Aug 25, 2026
bb4cd9a
feat(studio): add Uploads asset library UI
faresmohamed260 Aug 25, 2026
97569ee
style(studio): match Gallery Uploads reference layout
faresmohamed260 Aug 25, 2026
11dedfd
feat(studio): enable Gallery Uploads library
faresmohamed260 Aug 25, 2026
cd52f4e
feat(studio): reuse Gallery uploads as generation references
faresmohamed260 Aug 25, 2026
23fbaa7
test(studio): add Uploads library contract coverage
faresmohamed260 Aug 25, 2026
116a8ea
test(studio): gate persistent Uploads library contract
faresmohamed260 Aug 25, 2026
1f8aae3
test(studio): capture persistent Uploads library states
faresmohamed260 Aug 25, 2026
c1eab89
db(studio): make upload asset migration replay-safe
faresmohamed260 Aug 25, 2026
ede6d23
ci(studio): stabilize desktop Gallery pointer emulation
faresmohamed260 Aug 25, 2026
c04dfb3
ci(studio): fix Gallery hover patch workflow syntax
faresmohamed260 Aug 25, 2026
8fc4634
test(studio): stabilize Gallery desktop pointer capability
github-actions[bot] Aug 25, 2026
bf0db6a
ci(studio): remove temporary Gallery hover patch workflow
faresmohamed260 Aug 25, 2026
7a2a3b4
style(studio): match mobile Uploads portrait card proportions
faresmohamed260 Aug 25, 2026
bd4d438
ci(studio): stage Uploads library visual baselines
faresmohamed260 Aug 25, 2026
43376c0
test(studio): approve persistent Uploads library visuals
github-actions[bot] Aug 25, 2026
d7a2b96
ci(studio): remove temporary Uploads baseline workflow
faresmohamed260 Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 52 additions & 8 deletions apps/studio/api/_r2.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

export const r2Bucket = String(process.env.R2_BUCKET_NAME || 'saga-studio-media').trim();
Expand All @@ -18,16 +18,25 @@ export function getR2Client() {
}

export function isSourceKey(key) {
return /^sources\/\d{4}\/\d{2}\/[0-9a-f-]{36}\.(png|jpg|jpeg|webp)$/i.test(String(key || ''));
return /^(?:sources|uploads)\/\d{4}\/\d{2}\/[0-9a-f-]{36}\.(png|jpg|jpeg|webp)$/i.test(String(key || ''));
}

export async function createSourceUploadUrl({ key, contentType, expiresIn = 300 }) {
export function isLibraryUploadKey(key) {
return /^uploads\/\d{4}\/\d{2}\/[0-9a-f-]{36}\.(png|jpg|jpeg|webp)$/i.test(String(key || ''));
}

function requireClient() {
const client = getR2Client();
if (!client) {
const error = new Error('R2 storage is not configured');
error.statusCode = 503;
throw error;
}
return client;
}

export async function createSourceUploadUrl({ key, contentType, expiresIn = 300 }) {
const client = requireClient();
const command = new PutObjectCommand({
Bucket: r2Bucket,
Key: key,
Expand All @@ -36,18 +45,53 @@ export async function createSourceUploadUrl({ key, contentType, expiresIn = 300
return getSignedUrl(client, command, { expiresIn });
}

export async function readSourceObject(key, maxBytes) {
export async function createSourceReadUrl({ key, expiresIn = 3600, downloadName = '' }) {
if (!isSourceKey(key)) {
const error = new Error('Invalid source key');
error.statusCode = 400;
throw error;
}
const client = getR2Client();
if (!client) {
const error = new Error('R2 storage is not configured');
error.statusCode = 503;
const client = requireClient();
const command = new GetObjectCommand({
Bucket: r2Bucket,
Key: key,
...(downloadName ? { ResponseContentDisposition: `attachment; filename="${String(downloadName).replace(/["\\\r\n]/g, '_')}"` } : {}),
});
return getSignedUrl(client, command, { expiresIn });
}

export async function headSourceObject(key) {
if (!isSourceKey(key)) {
const error = new Error('Invalid source key');
error.statusCode = 400;
throw error;
}
const client = requireClient();
const object = await client.send(new HeadObjectCommand({ Bucket: r2Bucket, Key: key }));
return {
size: Number(object.ContentLength || 0),
contentType: String(object.ContentType || 'application/octet-stream').split(';')[0].trim().toLowerCase(),
etag: String(object.ETag || '').replace(/^"|"$/g, ''),
};
}

export async function deleteSourceObject(key) {
if (!isSourceKey(key)) {
const error = new Error('Invalid source key');
error.statusCode = 400;
throw error;
}
const client = requireClient();
await client.send(new DeleteObjectCommand({ Bucket: r2Bucket, Key: key }));
}

export async function readSourceObject(key, maxBytes) {
if (!isSourceKey(key)) {
const error = new Error('Invalid source key');
error.statusCode = 400;
throw error;
}
const client = requireClient();
const object = await client.send(new GetObjectCommand({ Bucket: r2Bucket, Key: key }));
const length = Number(object.ContentLength || 0);
if (maxBytes && length > maxBytes) {
Expand Down
10 changes: 10 additions & 0 deletions apps/studio/api/history.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ function clampOffset(value) {
return Math.max(parsed, 0);
}

function cleanAfter(value) {
if (typeof value !== 'string' || !value) return '';
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? '' : parsed.toISOString();
}

export default async function handler(req, res) {
if (req.method !== 'GET') {
res.setHeader('Allow', 'GET');
Expand All @@ -25,6 +31,8 @@ export default async function handler(req, res) {
const model = typeof req.query?.model === 'string' ? req.query.model : '';
const search = typeof req.query?.search === 'string' ? req.query.search.trim().slice(0, 200) : '';
const sort = req.query?.sort === 'oldest' ? 'oldest' : 'newest';
const after = cleanAfter(req.query?.after);
const favoritesOnly = req.query?.favorite === 'true';

const params = new URLSearchParams();
params.set('select', 'id,status,kind,mode,model,prompt,negative_prompt,r2_key,media_url,thumbnail_r2_key,thumbnail_url,mime_type,resolution,width,height,thumbnail_width,thumbnail_height,duration_ms,seed,workflow_id,provider,error_message,metadata,is_favorite,created_at,started_at,completed_at');
Expand All @@ -35,6 +43,8 @@ export default async function handler(req, res) {
if (kind === 'image' || kind === 'video') params.set('kind', `eq.${kind}`);
if (model) params.set('model', `eq.${model}`);
if (search) params.set('prompt', `ilike.*${search.replace(/[*,]/g, ' ')}*`);
if (after) params.set('created_at', `gte.${after}`);
if (favoritesOnly) params.set('is_favorite', 'eq.true');

const [rows, modelRows] = await Promise.all([
supabaseRequest(`studio_generations?${params.toString()}`, { method: 'GET' }),
Expand Down
217 changes: 188 additions & 29 deletions apps/studio/api/uploads.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { randomUUID } from 'node:crypto';
import { createSourceUploadUrl } from './_r2.js';
import { createSourceReadUrl, createSourceUploadUrl, deleteSourceObject, headSourceObject, isLibraryUploadKey } from './_r2.js';
import { supabaseRequest } from './_supabase.js';

const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
const SUPPORTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);

function safeFilename(value) {
return String(value || 'input.png').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120);
return String(value || 'input.png').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120) || 'input.png';
}

function cleanDisplayName(value, fallback = 'Untitled upload') {
const clean = String(value || fallback).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim();
return (clean || fallback).slice(0, 240);
}

function extensionFor(contentType, filename) {
Expand All @@ -13,37 +22,187 @@ function extensionFor(contentType, filename) {
return ['png', 'jpg', 'jpeg', 'webp'].includes(ext) ? ext : 'png';
}

export default async function handler(req, res) {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return res.status(405).json({ error: 'Method not allowed' });
function clampLimit(value) {
const parsed = Number.parseInt(String(value || '60'), 10);
return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), 100) : 60;
}

function clampOffset(value) {
const parsed = Number.parseInt(String(value || '0'), 10);
return Number.isFinite(parsed) ? Math.max(parsed, 0) : 0;
}

function positiveInteger(value) {
const parsed = Number.parseInt(String(value || ''), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}

function isUuid(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(value || ''));
}

async function presentAsset(row) {
const [url, downloadUrl] = await Promise.all([
createSourceReadUrl({ key: row.r2_key, expiresIn: 3600 }),
createSourceReadUrl({ key: row.r2_key, expiresIn: 3600, downloadName: row.filename || row.display_name || 'upload' }),
]);
return {
id: row.id,
key: row.r2_key,
filename: row.filename,
name: row.display_name,
mimeType: row.mime_type,
size: Number(row.size_bytes || 0),
width: row.width,
height: row.height,
favorite: Boolean(row.is_favorite),
metadata: row.metadata || {},
createdAt: row.created_at,
updatedAt: row.updated_at,
url,
downloadUrl,
};
}

async function listUploads(req, res) {
const limit = clampLimit(req.query?.limit);
const offset = clampOffset(req.query?.offset);
const sort = req.query?.sort === 'oldest' ? 'oldest' : 'newest';
const favoriteOnly = req.query?.favorite === 'true';
const search = typeof req.query?.search === 'string' ? req.query.search.trim().slice(0, 200) : '';

const params = new URLSearchParams();
params.set('select', 'id,r2_key,filename,display_name,mime_type,size_bytes,width,height,is_favorite,metadata,created_at,updated_at');
params.set('order', sort === 'oldest' ? 'created_at.asc,id.asc' : 'created_at.desc,id.desc');
params.set('limit', String(limit + 1));
params.set('offset', String(offset));
if (favoriteOnly) params.set('is_favorite', 'eq.true');
if (search) params.set('display_name', `ilike.*${search.replace(/[*,]/g, ' ')}*`);

const rows = await supabaseRequest(`studio_uploads?${params.toString()}`, { method: 'GET' });
const allRows = Array.isArray(rows) ? rows : [];
const hasMore = allRows.length > limit;
const visibleRows = hasMore ? allRows.slice(0, limit) : allRows;
const items = await Promise.all(visibleRows.map(presentAsset));
return res.status(200).json({
items,
page: { limit, offset, nextOffset: hasMore ? offset + items.length : null, hasMore },
});
}

async function createUploadTicket(body, res) {
const contentType = String(body.contentType || '').split(';')[0].trim().toLowerCase();
const filename = safeFilename(body.filename);
const size = Number(body.size || 0);
if (!SUPPORTED_TYPES.has(contentType)) {
return res.status(415).json({ error: 'Only PNG, JPEG, and WebP source uploads are supported' });
}
if (!Number.isFinite(size) || size <= 0 || size > MAX_UPLOAD_BYTES) {
return res.status(413).json({ error: 'Source upload must be between 1 byte and 25 MB' });
}

const now = new Date();
const prefix = body.purpose === 'library-upload' ? 'uploads' : 'sources';
const key = `${prefix}/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${randomUUID()}.${extensionFor(contentType, filename)}`;
const uploadUrl = await createSourceUploadUrl({ key, contentType, expiresIn: 300 });
return res.status(201).json({
key,
uploadUrl,
method: 'PUT',
contentType,
expiresIn: 300,
maxBytes: MAX_UPLOAD_BYTES,
});
}

async function completeLibraryUpload(body, res) {
const key = String(body.key || '');
if (!isLibraryUploadKey(key)) return res.status(400).json({ error: 'Invalid library upload key' });

const filename = safeFilename(body.filename);
const displayName = cleanDisplayName(body.displayName, String(body.filename || 'Untitled upload').replace(/\.[^.]+$/, ''));
const expectedType = String(body.contentType || '').split(';')[0].trim().toLowerCase();
const expectedSize = Number(body.size || 0);
if (!SUPPORTED_TYPES.has(expectedType)) return res.status(415).json({ error: 'Unsupported library upload type' });
if (!Number.isFinite(expectedSize) || expectedSize <= 0 || expectedSize > MAX_UPLOAD_BYTES) return res.status(413).json({ error: 'Invalid library upload size' });

const existingParams = new URLSearchParams({ select: 'id,r2_key,filename,display_name,mime_type,size_bytes,width,height,is_favorite,metadata,created_at,updated_at', r2_key: `eq.${key}`, limit: '1' });
const existingRows = await supabaseRequest(`studio_uploads?${existingParams.toString()}`, { method: 'GET' });
if (Array.isArray(existingRows) && existingRows[0]) return res.status(200).json({ item: await presentAsset(existingRows[0]) });

const object = await headSourceObject(key);
if (object.size <= 0 || object.size > MAX_UPLOAD_BYTES || object.size !== expectedSize) {
return res.status(409).json({ error: 'Uploaded object size does not match the upload ticket' });
}
if (object.contentType !== expectedType) {
return res.status(409).json({ error: 'Uploaded object type does not match the upload ticket' });
}

const rows = await supabaseRequest('studio_uploads?select=*', {
method: 'POST',
headers: { Prefer: 'return=representation' },
body: JSON.stringify({
r2_key: key,
filename,
display_name: displayName,
mime_type: expectedType,
size_bytes: expectedSize,
width: positiveInteger(body.width),
height: positiveInteger(body.height),
metadata: { etag: object.etag || null, source: 'gallery-upload' },
}),
});
const row = Array.isArray(rows) ? rows[0] : rows;
return res.status(201).json({ item: await presentAsset(row) });
}

async function updateUpload(req, res) {
const body = typeof req.body === 'object' && req.body ? req.body : {};
const id = String(body.id || req.query?.id || '');
if (!isUuid(id)) return res.status(400).json({ error: 'Valid upload id is required' });

const patch = { updated_at: new Date().toISOString() };
if (Object.hasOwn(body, 'displayName')) patch.display_name = cleanDisplayName(body.displayName);
if (Object.hasOwn(body, 'favorite')) patch.is_favorite = Boolean(body.favorite);
if (Object.keys(patch).length === 1) return res.status(400).json({ error: 'No upload changes were supplied' });

const rows = await supabaseRequest(`studio_uploads?id=eq.${encodeURIComponent(id)}&select=*`, {
method: 'PATCH',
headers: { Prefer: 'return=representation' },
body: JSON.stringify(patch),
});
const row = Array.isArray(rows) ? rows[0] : rows;
if (!row) return res.status(404).json({ error: 'Upload not found' });
return res.status(200).json({ item: await presentAsset(row) });
}

async function deleteUpload(req, res) {
const id = String(req.query?.id || req.body?.id || '');
if (!isUuid(id)) return res.status(400).json({ error: 'Valid upload id is required' });

const rows = await supabaseRequest(`studio_uploads?id=eq.${encodeURIComponent(id)}&select=id,r2_key&limit=1`, { method: 'GET' });
const row = Array.isArray(rows) ? rows[0] : null;
if (!row) return res.status(404).json({ error: 'Upload not found' });

await deleteSourceObject(row.r2_key);
await supabaseRequest(`studio_uploads?id=eq.${encodeURIComponent(id)}`, { method: 'DELETE' });
return res.status(200).json({ deleted: true, id });
}

export default async function handler(req, res) {
try {
const body = typeof req.body === 'object' && req.body ? req.body : {};
const contentType = String(body.contentType || '').split(';')[0].trim().toLowerCase();
const filename = safeFilename(body.filename);
const size = Number(body.size || 0);
if (!['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) {
return res.status(415).json({ error: 'Only PNG, JPEG, and WebP source uploads are supported' });
}
if (!Number.isFinite(size) || size <= 0 || size > 25 * 1024 * 1024) {
return res.status(413).json({ error: 'Source upload must be between 1 byte and 25 MB' });
if (req.method === 'GET') return await listUploads(req, res);
if (req.method === 'POST') {
const body = typeof req.body === 'object' && req.body ? req.body : {};
if (body.phase === 'complete') return await completeLibraryUpload(body, res);
return await createUploadTicket(body, res);
}

const now = new Date();
const key = `sources/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${randomUUID()}.${extensionFor(contentType, filename)}`;
const uploadUrl = await createSourceUploadUrl({ key, contentType, expiresIn: 300 });
return res.status(201).json({
key,
uploadUrl,
method: 'PUT',
contentType,
expiresIn: 300,
maxBytes: 25 * 1024 * 1024,
});
if (req.method === 'PATCH') return await updateUpload(req, res);
if (req.method === 'DELETE') return await deleteUpload(req, res);
res.setHeader('Allow', 'GET, POST, PATCH, DELETE');
return res.status(405).json({ error: 'Method not allowed' });
} catch (error) {
console.error('Source upload ticket failed', error);
return res.status(error?.statusCode || 500).json({ error: error?.message || 'Could not create source upload ticket' });
console.error('Uploads library request failed', error);
return res.status(error?.statusCode || 500).json({ error: error?.message || 'Uploads library request failed' });
}
}
1 change: 1 addition & 0 deletions apps/studio/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#080a0f" />
<link rel="stylesheet" href="/src/gallery-controls.css" />
<link rel="stylesheet" href="/src/gallery-library-redesign.css" />
<link rel="stylesheet" href="/src/library.css" />
<link rel="stylesheet" href="/src/picker-stability.css" />
<link rel="stylesheet" href="/src/features/create/audio-control.css" />
Expand Down
4 changes: 2 additions & 2 deletions apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "node scripts/check-workflow-contract.mjs && node scripts/check-worker-registry-contract.mjs && node scripts/check-resolution-contract.mjs && node scripts/check-generate-action-contract.mjs && node scripts/check-audio-control-contract.mjs && node scripts/check-generation-lifecycle-contract.mjs && node scripts/check-accessibility-polish-contract.mjs && vite build",
"build": "node scripts/check-workflow-contract.mjs && node scripts/check-worker-registry-contract.mjs && node scripts/check-resolution-contract.mjs && node scripts/check-generate-action-contract.mjs && node scripts/check-audio-control-contract.mjs && node scripts/check-generation-lifecycle-contract.mjs && node scripts/check-accessibility-polish-contract.mjs && node scripts/check-uploads-library-contract.mjs && vite build",
"test:poster": "node scripts/check-video-poster-contract.mjs",
"preview": "vite preview",
"visual:responsive": "node scripts/capture-responsive-preview.mjs",
"visual:preview": "node scripts/capture-ui-preview.mjs && node scripts/capture-video-output-preview.mjs && node scripts/capture-audio-state-preview.mjs && node scripts/capture-gallery-preview.mjs && node scripts/capture-jobs-preview.mjs && node scripts/capture-gallery-thumbnail-preview.mjs && npm run visual:responsive",
"visual:preview": "node scripts/capture-ui-preview.mjs && node scripts/capture-video-output-preview.mjs && node scripts/capture-audio-state-preview.mjs && node scripts/capture-gallery-preview.mjs && node scripts/capture-uploads-preview.mjs && node scripts/capture-jobs-preview.mjs && node scripts/capture-gallery-thumbnail-preview.mjs && npm run visual:responsive",
"visual:regression": "node scripts/check-visual-regression.mjs"
},
"dependencies": {
Expand Down
Loading
Loading