improved video and audio playback functionality
This commit is contained in:
parent
80fe56c6f9
commit
7080a33177
@ -510,7 +510,8 @@ Get current runtime context (environment detection).
|
||||
|
||||
**Permissions**: `CREATE_PROJECTS`, `READ_PROJECTS`, `UPDATE_PROJECTS`, `DELETE_PROJECTS`
|
||||
|
||||
**Runtime Public Access**: GET endpoints accessible without auth in production mode.
|
||||
**Runtime Public Access**: GET endpoints are accessible without auth in
|
||||
production mode for public presentations.
|
||||
|
||||
### POST /api/projects
|
||||
|
||||
@ -647,7 +648,11 @@ Get PWA offline manifest for a project.
|
||||
|
||||
**Permissions**: `CREATE_TOUR_PAGES`, `READ_TOUR_PAGES`, etc.
|
||||
|
||||
**Runtime Public Access**: GET endpoints accessible without auth in production mode.
|
||||
**Runtime Public Access**: GET endpoints are accessible without auth in
|
||||
production mode for public presentations. Public tour-page responses retain
|
||||
background media URLs, video autoplay/loop/muted/start/end, audio
|
||||
loop/start/end, design dimensions, `ui_schema_json`, and
|
||||
`global_ui_controls_settings_json`.
|
||||
|
||||
### POST /api/tour_pages/reorder
|
||||
|
||||
@ -753,8 +758,18 @@ Duplicates a constructor/dev page into a new independent dev page.
|
||||
"sort_order": 1,
|
||||
"background_image_url": "assets/bg.jpg",
|
||||
"background_video_url": null,
|
||||
"background_video_autoplay": true,
|
||||
"background_video_loop": false,
|
||||
"background_video_muted": false,
|
||||
"background_video_start_time": 2,
|
||||
"background_video_end_time": 30,
|
||||
"background_audio_url": "assets/ambient.mp3",
|
||||
"background_audio_loop": true,
|
||||
"background_audio_start_time": 1.5,
|
||||
"background_audio_end_time": 12,
|
||||
"background_loop": true,
|
||||
"design_width": 1920,
|
||||
"design_height": 1080,
|
||||
"requires_auth": false,
|
||||
"ui_schema_json": {
|
||||
"elements": [],
|
||||
|
||||
@ -341,7 +341,6 @@ Individual pages/scenes within a tour.
|
||||
| `background_video_start_time` | DECIMAL(10,1) | nullable | Background video start time (seconds) |
|
||||
| `background_video_end_time` | DECIMAL(10,1) | nullable | Background video end time (seconds) |
|
||||
| `background_video_play_once` | BOOLEAN | NOT NULL, default: false | Play video only once per session (show last frame on revisit) |
|
||||
| `background_audio_autoplay` | BOOLEAN | NOT NULL, default: true | Autoplay background audio |
|
||||
| `background_audio_loop` | BOOLEAN | NOT NULL, default: true | Loop background audio |
|
||||
| `background_audio_start_time` | DECIMAL(10,1) | nullable | Background audio start time (seconds) |
|
||||
| `background_audio_end_time` | DECIMAL(10,1) | nullable | Background audio end time (seconds) |
|
||||
@ -923,6 +922,7 @@ All foreign key constraints are enforced at the database level via migration `20
|
||||
| `20260613000001-add-background-embed-url-to-tour-pages.js` | Adds background_embed_url to tour_pages for 360/embed page backgrounds |
|
||||
| `20260626000001-add-private-production-presentation-access.js` | Adds project production visibility and customer access grants for private production presentations |
|
||||
| `20260626000002-grant-account-manager-create-users.js` | Grants `CREATE_USERS` to Account Manager for customer viewer creation |
|
||||
| `20260731000001-remove-background-audio-autoplay.js` | Removes the unused background audio autoplay column; rollback restores it with its former default |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ changes that run automatically on server startup.
|
||||
|
||||
**Location:** `backend/src/db/migrations/`
|
||||
|
||||
**Files:** 34+ migration files (as of June 2026)
|
||||
**Files:** 38 migration files (as of July 2026)
|
||||
|
||||
**Migration safety policy:** Do not rewrite, rename, or reformat already
|
||||
applied migration files. Production databases track migration names in
|
||||
@ -53,7 +53,10 @@ backend/
|
||||
├── 20260626000001-*.js # Private production presentation access
|
||||
├── 20260626000002-*.js # Account manager user creation permission
|
||||
├── 20260628000001-*.js # Global UI-control settings tables
|
||||
└── 20260628000005-*.js # Existing-project UI-control snapshots
|
||||
├── 20260628000005-*.js # Existing-project UI-control snapshots
|
||||
├── 20260628000006-*.js # Page-element role permissions
|
||||
├── 20260702000001-*.js # Runtime asset lookup indexes
|
||||
└── 20260731000001-*.js # Remove unused audio autoplay column
|
||||
```
|
||||
|
||||
---
|
||||
@ -633,6 +636,7 @@ module.exports = {
|
||||
| `add-background-video-settings` | Add video playback settings (autoplay, loop, muted, start/end time) to tour_pages |
|
||||
| `add-design-dimensions-to-projects` | Add `design_width`, `design_height` to projects table |
|
||||
| `add-design-dimensions-to-tour-pages` | Add `design_width`, `design_height` to tour_pages table |
|
||||
| `remove-background-audio-autoplay` | Remove the unused background audio autoplay column from tour_pages |
|
||||
|
||||
### Table Renames
|
||||
|
||||
@ -823,7 +827,21 @@ explicit rollback/backup plan.
|
||||
| 21 | 20260403000001 | add-background-video-settings | Column |
|
||||
| 22 | 20260409000001 | add-design-dimensions-to-projects | Column |
|
||||
| 23 | 20260409111309 | add-design-dimensions-to-tour-pages | Column |
|
||||
| 24 | 20260605000001 | add-background-audio-settings | Column |
|
||||
| 24 | 20260413091125 | add-reversed-variant-type | Column |
|
||||
| 25 | 20260430000001 | add-transition-settings | Column |
|
||||
| 26 | 20260501000001 | simplify-transitions-add-overlay-color | Column |
|
||||
| 27 | 20260501000002 | create-project-transition-settings | Schema |
|
||||
| 28 | 20260529000001 | add-embed-asset-support | Data |
|
||||
| 29 | 20260605000001 | add-background-audio-settings | Column |
|
||||
| 30 | 20260613000001 | add-background-embed-url-to-tour-pages | Column |
|
||||
| 31 | 20260625000001 | add-frame-rate-to-assets | Column |
|
||||
| 32 | 20260626000001 | add-private-production-presentation-access | Schema |
|
||||
| 33 | 20260626000002 | grant-account-manager-create-users | Data |
|
||||
| 34 | 20260628000001 | create-ui-control-settings | Schema |
|
||||
| 35 | 20260628000005 | snapshot-existing-project-ui-controls | Data |
|
||||
| 36 | 20260628000006 | backfill-page-elements-role-permissions | Data |
|
||||
| 37 | 20260702000001 | add-asset-runtime-lookup-indexes | Index |
|
||||
| 38 | 20260731000001 | remove-background-audio-autoplay | Column |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -290,6 +290,11 @@ const sampleDataSeeder: SequelizeSeeder = {
|
||||
| PWA Caches | 3 | Offline cache configs |
|
||||
| Access Logs | 3 | Visitor tracking |
|
||||
|
||||
The sample `Arena Floor` page uses non-default video and audio playback bounds
|
||||
plus explicit `1920x1080` page dimensions. This makes opt-in sample data useful
|
||||
for verifying that Save to Stage, Publish, and public-runtime response
|
||||
sanitization preserve playback settings.
|
||||
|
||||
#### Sample Projects
|
||||
|
||||
```javascript
|
||||
|
||||
@ -441,6 +441,7 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
|
||||
'logo_url',
|
||||
'favicon_url',
|
||||
'og_image_url',
|
||||
'production_presentation_visibility',
|
||||
],
|
||||
tour_pages: [
|
||||
'id',
|
||||
@ -452,10 +453,22 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
|
||||
'sort_order',
|
||||
'background_image_url',
|
||||
'background_video_url',
|
||||
'background_embed_url',
|
||||
'background_audio_url',
|
||||
'background_audio_loop',
|
||||
'background_audio_start_time',
|
||||
'background_audio_end_time',
|
||||
'background_loop',
|
||||
'background_video_autoplay',
|
||||
'background_video_loop',
|
||||
'background_video_muted',
|
||||
'background_video_start_time',
|
||||
'background_video_end_time',
|
||||
'design_width',
|
||||
'design_height',
|
||||
'requires_auth',
|
||||
'ui_schema_json',
|
||||
'global_ui_controls_settings_json',
|
||||
],
|
||||
project_audio_tracks: [
|
||||
'id',
|
||||
@ -470,9 +483,28 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
|
||||
'sort_order',
|
||||
'is_enabled',
|
||||
],
|
||||
global_transition_defaults: [
|
||||
'id',
|
||||
'transition_type',
|
||||
'duration_ms',
|
||||
'easing',
|
||||
'overlay_color',
|
||||
],
|
||||
project_transition_settings: [
|
||||
'id',
|
||||
'projectId',
|
||||
'environment',
|
||||
'transition_type',
|
||||
'duration_ms',
|
||||
'easing',
|
||||
'overlay_color',
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
Background audio starts after the runtime's interaction/unlock flow and uses
|
||||
the exposed loop/start/end settings.
|
||||
|
||||
#### Function: blockNonPublicRuntimeListEndpoints
|
||||
|
||||
Restricts public runtime requests to list endpoints only.
|
||||
|
||||
@ -426,8 +426,11 @@ Typed manual CRUD route for tour pages. In addition to standard create/update/de
|
||||
**Schema Fields:**
|
||||
|
||||
- `source_key`, `name`, `slug`
|
||||
- `background_image_url`, `background_video_url`, `background_audio_url`
|
||||
- `ui_schema_json`, `sort_order`
|
||||
- `background_image_url`, `background_video_url`, `background_embed_url`,
|
||||
`background_audio_url`
|
||||
- Video autoplay/loop/muted/start/end and audio loop/start/end settings
|
||||
- `design_width`, `design_height`, `requires_auth`
|
||||
- `ui_schema_json`, `global_ui_controls_settings_json`, `sort_order`
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
|
||||
@ -166,7 +166,6 @@ class Tour_pagesDBApi extends GenericDBApi {
|
||||
background_video_url: data.background_video_url || null,
|
||||
background_embed_url: data.background_embed_url || null,
|
||||
background_audio_url: data.background_audio_url || null,
|
||||
background_audio_autoplay: data.background_audio_autoplay ?? true,
|
||||
background_audio_loop: data.background_audio_loop ?? true,
|
||||
background_audio_start_time: data.background_audio_start_time ?? null,
|
||||
background_audio_end_time: data.background_audio_end_time ?? null,
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, _Sequelize) {
|
||||
await queryInterface.removeColumn(
|
||||
'tour_pages',
|
||||
'background_audio_autoplay',
|
||||
);
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn('tour_pages', 'background_audio_autoplay', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
@ -75,12 +75,6 @@ const defineTourPagesModel: SequelizeModelFactory = (sequelize, DataTypes) => {
|
||||
type: DataTypes.TEXT,
|
||||
},
|
||||
|
||||
background_audio_autoplay: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
|
||||
background_audio_loop: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
|
||||
@ -344,8 +344,28 @@ const TourPagesData = [
|
||||
background_video_url:
|
||||
'https://cdn.platform.com/cardiff/video/floor-loop.mp4',
|
||||
|
||||
background_video_autoplay: true,
|
||||
|
||||
background_video_loop: false,
|
||||
|
||||
background_video_muted: false,
|
||||
|
||||
background_video_start_time: 2,
|
||||
|
||||
background_video_end_time: 30,
|
||||
|
||||
background_audio_url: '',
|
||||
|
||||
background_audio_loop: false,
|
||||
|
||||
background_audio_start_time: 1.5,
|
||||
|
||||
background_audio_end_time: 12,
|
||||
|
||||
design_width: 1920,
|
||||
|
||||
design_height: 1080,
|
||||
|
||||
background_loop: true,
|
||||
|
||||
requires_auth: true,
|
||||
|
||||
@ -33,7 +33,17 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS: RuntimePublicFieldMap = {
|
||||
'background_video_url',
|
||||
'background_embed_url',
|
||||
'background_audio_url',
|
||||
'background_audio_loop',
|
||||
'background_audio_start_time',
|
||||
'background_audio_end_time',
|
||||
'background_loop',
|
||||
'background_video_autoplay',
|
||||
'background_video_loop',
|
||||
'background_video_muted',
|
||||
'background_video_start_time',
|
||||
'background_video_end_time',
|
||||
'design_width',
|
||||
'design_height',
|
||||
'requires_auth',
|
||||
'ui_schema_json',
|
||||
'global_ui_controls_settings_json',
|
||||
|
||||
@ -16,6 +16,7 @@ interface CrudResource {
|
||||
schema: string;
|
||||
description: string;
|
||||
runtimePublicRead?: boolean;
|
||||
runtimePublicReadDescription?: string;
|
||||
}
|
||||
|
||||
interface OpenApiDocument {
|
||||
@ -273,8 +274,11 @@ const crudPaths = (resource: CrudResource): OpenApiPaths => {
|
||||
const readSecurity = resource.runtimePublicRead
|
||||
? [{ bearerAuth: [] }, {}]
|
||||
: bearerSecurity;
|
||||
const runtimePublicReadDescription = resource.runtimePublicReadDescription
|
||||
? ` ${resource.runtimePublicReadDescription}`
|
||||
: '';
|
||||
const readDescription = resource.runtimePublicRead
|
||||
? `${resource.description}. GET requests are public for allowed production runtime reads and authenticated otherwise.`
|
||||
? `${resource.description}. GET requests are public for allowed production runtime reads and authenticated otherwise.${runtimePublicReadDescription}`
|
||||
: resource.description;
|
||||
const commonReadParameters = resource.runtimePublicRead
|
||||
? [...runtimeHeaders]
|
||||
@ -600,7 +604,6 @@ const schemas: Record<string, OpenApiSchema> = {
|
||||
background_video_url: nullable({ type: 'string', maxLength: 4096 }),
|
||||
background_embed_url: nullable({ type: 'string', maxLength: 8192 }),
|
||||
background_audio_url: nullable({ type: 'string', maxLength: 4096 }),
|
||||
background_audio_autoplay: { type: 'boolean' },
|
||||
background_audio_loop: { type: 'boolean' },
|
||||
background_audio_start_time: nullable({ type: 'number', minimum: 0 }),
|
||||
background_audio_end_time: nullable({ type: 'number', minimum: 0 }),
|
||||
@ -1088,6 +1091,8 @@ const crudResources: CrudResource[] = [
|
||||
schema: 'TourPage',
|
||||
description: 'tour pages',
|
||||
runtimePublicRead: true,
|
||||
runtimePublicReadDescription:
|
||||
'Public responses retain background media URLs, background audio loop/start/end settings, background video autoplay/loop/muted/start/end settings, page design dimensions, UI schema, and page-level global UI control settings.',
|
||||
},
|
||||
{
|
||||
path: '/api/assets',
|
||||
|
||||
@ -658,7 +658,6 @@ class TourPagesService extends BaseService {
|
||||
background_video_url: source.background_video_url || '',
|
||||
background_embed_url: source.background_embed_url || '',
|
||||
background_audio_url: source.background_audio_url || '',
|
||||
background_audio_autoplay: source.background_audio_autoplay,
|
||||
background_audio_loop: source.background_audio_loop,
|
||||
background_audio_start_time: source.background_audio_start_time,
|
||||
background_audio_end_time: source.background_audio_end_time,
|
||||
|
||||
@ -90,7 +90,6 @@ export interface TourPageData {
|
||||
background_video_url?: string | null | undefined;
|
||||
background_embed_url?: string | null | undefined;
|
||||
background_audio_url?: string | null | undefined;
|
||||
background_audio_autoplay?: boolean | undefined;
|
||||
background_audio_loop?: boolean | undefined;
|
||||
background_audio_start_time?: number | null | undefined;
|
||||
background_audio_end_time?: number | null | undefined;
|
||||
@ -264,7 +263,6 @@ export interface TourPageFieldMapping {
|
||||
background_video_url: string | null;
|
||||
background_embed_url: string | null;
|
||||
background_audio_url: string | null;
|
||||
background_audio_autoplay: boolean;
|
||||
background_audio_loop: boolean;
|
||||
background_audio_start_time: number | null;
|
||||
background_audio_end_time: number | null;
|
||||
|
||||
@ -151,7 +151,6 @@ const tourPageData = Joi.object({
|
||||
background_video_url: Joi.string().allow('', null).max(4096),
|
||||
background_embed_url: Joi.string().allow('', null).max(8192),
|
||||
background_audio_url: Joi.string().allow('', null).max(4096),
|
||||
background_audio_autoplay: Joi.boolean(),
|
||||
background_audio_loop: Joi.boolean(),
|
||||
background_audio_start_time: Joi.number().min(0).allow(null),
|
||||
background_audio_end_time: Joi.number().min(0).allow(null),
|
||||
|
||||
82
backend/tests/background-audio-autoplay-migration.test.ts
Normal file
82
backend/tests/background-audio-autoplay-migration.test.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import test from 'node:test';
|
||||
|
||||
interface MigrationQueryInterface {
|
||||
removeColumn(table: string, column: string): Promise<void>;
|
||||
addColumn(
|
||||
table: string,
|
||||
column: string,
|
||||
definition: Record<string, unknown>,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
interface MigrationModule {
|
||||
up(
|
||||
queryInterface: MigrationQueryInterface,
|
||||
sequelize: Record<string, unknown>,
|
||||
): Promise<void>;
|
||||
down(
|
||||
queryInterface: MigrationQueryInterface,
|
||||
sequelize: Record<string, unknown>,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object';
|
||||
}
|
||||
|
||||
function isMigrationModule(value: unknown): value is MigrationModule {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.up === 'function' &&
|
||||
typeof value.down === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const loadedMigration: unknown = require('../src/db/migrations/20260731000001-remove-background-audio-autoplay.js');
|
||||
|
||||
assert.ok(isMigrationModule(loadedMigration));
|
||||
|
||||
void test('background audio autoplay migration removes and restores the column', async () => {
|
||||
const calls: Array<{
|
||||
operation: 'add' | 'remove';
|
||||
table: string;
|
||||
column: string;
|
||||
definition?: Record<string, unknown>;
|
||||
}> = [];
|
||||
const queryInterface: MigrationQueryInterface = {
|
||||
removeColumn(table, column) {
|
||||
calls.push({ operation: 'remove', table, column });
|
||||
return Promise.resolve();
|
||||
},
|
||||
addColumn(table, column, definition) {
|
||||
calls.push({ operation: 'add', table, column, definition });
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
const booleanType = Symbol('BOOLEAN');
|
||||
const sequelize = { BOOLEAN: booleanType };
|
||||
|
||||
await loadedMigration.up(queryInterface, sequelize);
|
||||
await loadedMigration.down(queryInterface, sequelize);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
operation: 'remove',
|
||||
table: 'tour_pages',
|
||||
column: 'background_audio_autoplay',
|
||||
},
|
||||
{
|
||||
operation: 'add',
|
||||
table: 'tour_pages',
|
||||
column: 'background_audio_autoplay',
|
||||
definition: {
|
||||
type: booleanType,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
33
backend/tests/integration/background-audio-schema.test.ts
Normal file
33
backend/tests/integration/background-audio-schema.test.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { QueryTypes } from 'sequelize';
|
||||
|
||||
import db from '../../src/db/models/index.ts';
|
||||
|
||||
void test.after(async () => {
|
||||
await db.sequelize.close();
|
||||
});
|
||||
|
||||
void test('tour_pages schema contains only active background audio settings', async (t) => {
|
||||
try {
|
||||
await db.sequelize.authenticate();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'unknown error';
|
||||
t.skip(`Database unavailable: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const columnRows = await db.sequelize.query<{ column_name: string }>(
|
||||
`SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'tour_pages'`,
|
||||
{ type: QueryTypes.SELECT },
|
||||
);
|
||||
const columns = new Set(columnRows.map((row) => row.column_name));
|
||||
|
||||
assert.equal(columns.has('background_audio_autoplay'), false);
|
||||
assert.ok(columns.has('background_audio_loop'));
|
||||
assert.ok(columns.has('background_audio_start_time'));
|
||||
assert.ok(columns.has('background_audio_end_time'));
|
||||
});
|
||||
@ -91,6 +91,16 @@ interface TourPageSeed {
|
||||
name: string;
|
||||
slug: string;
|
||||
sortOrder: number;
|
||||
backgroundVideoStartTime?: number | null;
|
||||
backgroundVideoEndTime?: number | null;
|
||||
backgroundVideoAutoplay?: boolean;
|
||||
backgroundVideoLoop?: boolean;
|
||||
backgroundVideoMuted?: boolean;
|
||||
backgroundAudioLoop?: boolean;
|
||||
backgroundAudioStartTime?: number | null;
|
||||
backgroundAudioEndTime?: number | null;
|
||||
designWidth?: number | null;
|
||||
designHeight?: number | null;
|
||||
}
|
||||
|
||||
function createTourPage(data: TourPageSeed, transaction: Transaction) {
|
||||
@ -106,18 +116,17 @@ function createTourPage(data: TourPageSeed, transaction: Transaction) {
|
||||
background_video_url: null,
|
||||
background_embed_url: null,
|
||||
background_audio_url: null,
|
||||
background_audio_autoplay: true,
|
||||
background_audio_loop: true,
|
||||
background_audio_start_time: null,
|
||||
background_audio_end_time: null,
|
||||
background_audio_loop: data.backgroundAudioLoop ?? true,
|
||||
background_audio_start_time: data.backgroundAudioStartTime ?? null,
|
||||
background_audio_end_time: data.backgroundAudioEndTime ?? null,
|
||||
background_loop: false,
|
||||
background_video_autoplay: true,
|
||||
background_video_loop: true,
|
||||
background_video_muted: true,
|
||||
background_video_start_time: null,
|
||||
background_video_end_time: null,
|
||||
design_width: null,
|
||||
design_height: null,
|
||||
background_video_autoplay: data.backgroundVideoAutoplay ?? true,
|
||||
background_video_loop: data.backgroundVideoLoop ?? true,
|
||||
background_video_muted: data.backgroundVideoMuted ?? true,
|
||||
background_video_start_time: data.backgroundVideoStartTime ?? null,
|
||||
background_video_end_time: data.backgroundVideoEndTime ?? null,
|
||||
design_width: data.designWidth ?? null,
|
||||
design_height: data.designHeight ?? null,
|
||||
requires_auth: false,
|
||||
ui_schema_json: {
|
||||
elements: [{ id: `${data.slug}-button`, type: 'button' }],
|
||||
@ -259,6 +268,16 @@ void test('copyDevToStage replaces stage content with dev pages, audio, and sett
|
||||
name: 'Dev Lobby',
|
||||
slug: 'lobby',
|
||||
sortOrder: 1,
|
||||
backgroundVideoAutoplay: false,
|
||||
backgroundVideoLoop: false,
|
||||
backgroundVideoMuted: false,
|
||||
backgroundVideoStartTime: 2,
|
||||
backgroundVideoEndTime: 12.5,
|
||||
backgroundAudioLoop: false,
|
||||
backgroundAudioStartTime: 1.5,
|
||||
backgroundAudioEndTime: 8,
|
||||
designWidth: 1920,
|
||||
designHeight: 1080,
|
||||
},
|
||||
transaction,
|
||||
);
|
||||
@ -357,6 +376,16 @@ void test('copyDevToStage replaces stage content with dev pages, audio, and sett
|
||||
assert.deepEqual(copiedPage.ui_schema_json, {
|
||||
elements: [{ id: 'lobby-button', type: 'button' }],
|
||||
});
|
||||
assert.equal(copiedPage.background_video_autoplay, false);
|
||||
assert.equal(copiedPage.background_video_loop, false);
|
||||
assert.equal(copiedPage.background_video_muted, false);
|
||||
assert.equal(Number(copiedPage.background_video_start_time), 2);
|
||||
assert.equal(Number(copiedPage.background_video_end_time), 12.5);
|
||||
assert.equal(copiedPage.background_audio_loop, false);
|
||||
assert.equal(Number(copiedPage.background_audio_start_time), 1.5);
|
||||
assert.equal(Number(copiedPage.background_audio_end_time), 8);
|
||||
assert.equal(copiedPage.design_width, 1920);
|
||||
assert.equal(copiedPage.design_height, 1080);
|
||||
|
||||
const stageAudios = await db.project_audio_tracks.findAll({
|
||||
where: { projectId: project.id, environment: 'stage' },
|
||||
@ -405,6 +434,16 @@ void test('copyStageToProduction leaves stage content intact while replacing pro
|
||||
name: 'Stage Intro',
|
||||
slug: 'intro',
|
||||
sortOrder: 1,
|
||||
backgroundVideoAutoplay: false,
|
||||
backgroundVideoLoop: false,
|
||||
backgroundVideoMuted: false,
|
||||
backgroundVideoStartTime: 2,
|
||||
backgroundVideoEndTime: 12.5,
|
||||
backgroundAudioLoop: false,
|
||||
backgroundAudioStartTime: 1.5,
|
||||
backgroundAudioEndTime: 8,
|
||||
designWidth: 1920,
|
||||
designHeight: 1080,
|
||||
},
|
||||
transaction,
|
||||
);
|
||||
@ -446,5 +485,15 @@ void test('copyStageToProduction leaves stage content intact while replacing pro
|
||||
assert.ok(productionPage);
|
||||
assert.notEqual(productionPage.id, sourcePage.id);
|
||||
assert.equal(productionPage.source_key, sourcePage.id);
|
||||
assert.equal(productionPage.background_video_autoplay, false);
|
||||
assert.equal(productionPage.background_video_loop, false);
|
||||
assert.equal(productionPage.background_video_muted, false);
|
||||
assert.equal(Number(productionPage.background_video_start_time), 2);
|
||||
assert.equal(Number(productionPage.background_video_end_time), 12.5);
|
||||
assert.equal(productionPage.background_audio_loop, false);
|
||||
assert.equal(Number(productionPage.background_audio_start_time), 1.5);
|
||||
assert.equal(Number(productionPage.background_audio_end_time), 8);
|
||||
assert.equal(productionPage.design_width, 1920);
|
||||
assert.equal(productionPage.design_height, 1080);
|
||||
});
|
||||
});
|
||||
|
||||
@ -134,6 +134,37 @@ void test('OpenAPI documents the completion-confirmed publishing result', () =>
|
||||
});
|
||||
});
|
||||
|
||||
void test('OpenAPI documents public tour-page playback fields', () => {
|
||||
const document = createTestDocument();
|
||||
const tourPageProperties = document.components.schemas.TourPage?.properties;
|
||||
const listOperation = document.paths['/api/tour_pages']?.get;
|
||||
|
||||
assert.ok(
|
||||
typeof tourPageProperties === 'object' && tourPageProperties !== null,
|
||||
);
|
||||
for (const field of [
|
||||
'background_audio_loop',
|
||||
'background_audio_start_time',
|
||||
'background_audio_end_time',
|
||||
'background_video_autoplay',
|
||||
'background_video_loop',
|
||||
'background_video_muted',
|
||||
'background_video_start_time',
|
||||
'background_video_end_time',
|
||||
'design_width',
|
||||
'design_height',
|
||||
]) {
|
||||
assert.ok(field in tourPageProperties, `Missing TourPage field: ${field}`);
|
||||
}
|
||||
assert.equal('background_audio_autoplay' in tourPageProperties, false);
|
||||
|
||||
assert.deepEqual(listOperation?.security, [{ bearerAuth: [] }, {}]);
|
||||
assert.match(
|
||||
String(listOperation?.description),
|
||||
/Public responses retain background media URLs/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('OpenAPI factory CRUD paths are generated consistently', () => {
|
||||
const document = createTestDocument();
|
||||
const resourcePath = '/api/assets';
|
||||
|
||||
@ -120,6 +120,18 @@ void test('sanitizePublicRuntimeListResponse strips non-public fields from rows
|
||||
count: 1,
|
||||
});
|
||||
|
||||
const runtimePagePresentationFields = {
|
||||
background_audio_loop: false,
|
||||
background_audio_start_time: '1.5',
|
||||
background_audio_end_time: '8.0',
|
||||
background_video_autoplay: false,
|
||||
background_video_loop: false,
|
||||
background_video_muted: false,
|
||||
background_video_start_time: '2.0',
|
||||
background_video_end_time: '12.5',
|
||||
design_width: 1920,
|
||||
design_height: 1080,
|
||||
};
|
||||
const recordReq = createRequest({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
@ -135,6 +147,7 @@ void test('sanitizePublicRuntimeListResponse strips non-public fields from rows
|
||||
recordRes.send({
|
||||
id: 'page-1',
|
||||
slug: 'intro',
|
||||
...runtimePagePresentationFields,
|
||||
ui_schema_json: { elements: [] },
|
||||
internal_note: 'do-not-leak',
|
||||
});
|
||||
@ -142,6 +155,7 @@ void test('sanitizePublicRuntimeListResponse strips non-public fields from rows
|
||||
assert.deepEqual(recordRes._getData(), {
|
||||
id: 'page-1',
|
||||
slug: 'intro',
|
||||
...runtimePagePresentationFields,
|
||||
ui_schema_json: { elements: [] },
|
||||
});
|
||||
});
|
||||
|
||||
@ -684,6 +684,17 @@ Create a tour page.
|
||||
"environment": "dev",
|
||||
"background_image_url": "https://...",
|
||||
"background_video_url": "https://...",
|
||||
"background_video_autoplay": true,
|
||||
"background_video_loop": false,
|
||||
"background_video_muted": false,
|
||||
"background_video_start_time": 2,
|
||||
"background_video_end_time": 30,
|
||||
"background_audio_url": "https://...",
|
||||
"background_audio_loop": true,
|
||||
"background_audio_start_time": 1.5,
|
||||
"background_audio_end_time": 12,
|
||||
"design_width": 1920,
|
||||
"design_height": 1080,
|
||||
"ui_schema_json": {}
|
||||
}
|
||||
}
|
||||
@ -699,6 +710,12 @@ List tour pages.
|
||||
- `projectId` - Filter by project
|
||||
- `environment` - Filter by environment
|
||||
|
||||
For an unauthenticated production runtime read, the response sanitizer keeps
|
||||
the media URLs, video autoplay/loop/muted/start/end settings, audio
|
||||
loop/start/end settings, page design dimensions, UI schema, and page-level
|
||||
global UI controls. It strips administrative fields that are not part of
|
||||
presentation playback.
|
||||
|
||||
### GET /api/tour_pages/:id
|
||||
|
||||
Get tour page by ID.
|
||||
|
||||
@ -48,7 +48,6 @@ Page-level ambient audio that plays behind all content with configurable playbac
|
||||
// Using useBackgroundAudioPlayback hook for controlled playback
|
||||
const { audioRef } = useBackgroundAudioPlayback({
|
||||
audioUrl: page.background_audio_url,
|
||||
autoplay: page.background_audio_autoplay ?? true,
|
||||
loop: page.background_audio_loop ?? true,
|
||||
startTime: page.background_audio_start_time ?? null,
|
||||
endTime: page.background_audio_end_time ?? null,
|
||||
@ -61,7 +60,6 @@ const { audioRef } = useBackgroundAudioPlayback({
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `background_audio_autoplay` | boolean | true | Start playback automatically |
|
||||
| `background_audio_loop` | boolean | true | Loop continuously |
|
||||
| `background_audio_start_time` | DECIMAL(10,1) \| null | null | Start playback at this time (seconds) |
|
||||
| `background_audio_end_time` | DECIMAL(10,1) \| null | null | Stop/loop at this time (seconds) |
|
||||
@ -70,6 +68,9 @@ const { audioRef } = useBackgroundAudioPlayback({
|
||||
|
||||
**Note:** When `background_audio_end_time` is set, looping is handled via JavaScript (`timeupdate` event) to properly seek back to `startTime`.
|
||||
|
||||
There is no page-level autoplay setting. Background audio follows the runtime
|
||||
interaction/unlock flow; page settings control only loop/start/end behavior.
|
||||
|
||||
### 2. Element Audio Effects
|
||||
|
||||
Interactive audio triggered by user hover and click on UI elements.
|
||||
@ -292,7 +293,6 @@ The Constructor provides UI controls for background audio settings via `Backgrou
|
||||
value={backgroundAudioUrl}
|
||||
options={audioAssetOptions}
|
||||
onChange={setBackgroundAudioUrl}
|
||||
audioAutoplay={pageBackground.audioSettings.autoplay}
|
||||
audioLoop={pageBackground.audioSettings.loop}
|
||||
audioStartTime={pageBackground.audioSettings.startTime}
|
||||
audioEndTime={pageBackground.audioSettings.endTime}
|
||||
@ -304,7 +304,6 @@ The Constructor provides UI controls for background audio settings via `Backgrou
|
||||
|
||||
| Setting | Control | Description |
|
||||
|---------|---------|-------------|
|
||||
| Autoplay | Checkbox | Start audio automatically on page load |
|
||||
| Loop | Checkbox | Continuously loop audio |
|
||||
| Start Time | Number input | Begin playback at specific time (seconds) |
|
||||
| End Time | Number input | Stop/loop at specific time (seconds) |
|
||||
@ -324,7 +323,6 @@ Passes audio settings to CanvasBackground for playback:
|
||||
const backgroundAudioUrl = navCurrentBgAudioUrl;
|
||||
|
||||
// Extract settings from selected page
|
||||
const audioAutoplay = selectedPage?.background_audio_autoplay ?? true;
|
||||
const audioLoop = selectedPage?.background_audio_loop ?? true;
|
||||
const audioStartTime = selectedPage?.background_audio_start_time != null
|
||||
? parseFloat(String(selectedPage.background_audio_start_time))
|
||||
@ -336,7 +334,6 @@ const audioEndTime = selectedPage?.background_audio_end_time != null
|
||||
// Pass to CanvasBackground
|
||||
<CanvasBackground
|
||||
backgroundAudioUrl={backgroundAudioUrl}
|
||||
audioAutoplay={audioAutoplay}
|
||||
audioLoop={audioLoop}
|
||||
audioStartTime={audioStartTime}
|
||||
audioEndTime={audioEndTime}
|
||||
@ -352,7 +349,6 @@ const audioEndTime = selectedPage?.background_audio_end_time != null
|
||||
const { audioRef } = useBackgroundAudioPlayback({
|
||||
audioUrl: backgroundAudioUrl,
|
||||
audioStoragePath,
|
||||
autoplay: audioAutoplay,
|
||||
loop: audioLoop,
|
||||
startTime: audioStartTime,
|
||||
endTime: audioEndTime,
|
||||
@ -382,7 +378,6 @@ Manages background audio playback with start/end time control and ducking integr
|
||||
interface UseBackgroundAudioPlaybackOptions {
|
||||
audioUrl?: string; // Audio URL (may be blob URL)
|
||||
audioStoragePath?: string; // Original storage path for play-once tracking
|
||||
autoplay?: boolean; // Default: true
|
||||
loop?: boolean; // Default: true
|
||||
startTime?: number | null; // Start at time (seconds)
|
||||
endTime?: number | null; // Stop/loop at time (seconds)
|
||||
@ -447,14 +442,15 @@ interface UseAudioEffectsResult {
|
||||
Audio settings are stored in the `tour_pages` table:
|
||||
|
||||
```sql
|
||||
-- Added via migration 20260605000001-add-background-audio-settings.js
|
||||
ALTER TABLE tour_pages
|
||||
ADD COLUMN background_audio_autoplay BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN background_audio_loop BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN background_audio_start_time DECIMAL(10, 1),
|
||||
ADD COLUMN background_audio_end_time DECIMAL(10, 1);
|
||||
background_audio_loop BOOLEAN NOT NULL DEFAULT true
|
||||
background_audio_start_time DECIMAL(10, 1) NULL
|
||||
background_audio_end_time DECIMAL(10, 1) NULL
|
||||
```
|
||||
|
||||
Migration `20260731000001-remove-background-audio-autoplay.js` removes the
|
||||
unused autoplay column. Its rollback recreates the former boolean column with
|
||||
the original `true` default.
|
||||
|
||||
---
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
@ -592,10 +592,21 @@ All entities include:
|
||||
projectId, environment,
|
||||
background_image_url,
|
||||
background_video_url,
|
||||
background_embed_url,
|
||||
background_audio_url,
|
||||
background_video_autoplay,
|
||||
background_video_loop,
|
||||
background_video_muted,
|
||||
background_video_start_time,
|
||||
background_video_end_time,
|
||||
background_audio_loop,
|
||||
background_audio_start_time,
|
||||
background_audio_end_time,
|
||||
background_loop,
|
||||
design_width, design_height,
|
||||
requires_auth,
|
||||
ui_schema_json, // Page layout/elements
|
||||
global_ui_controls_settings_json,
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@ -83,6 +83,12 @@ const effectiveAutoplay = (page.background_video_autoplay ?? true) && !shouldBlo
|
||||
|
||||
**Note:** When `background_video_end_time` is set, native HTML5 `loop` attribute is disabled and looping is handled via JavaScript (`timeupdate` event) to properly seek back to `startTime`.
|
||||
|
||||
For public production presentations, all five playback fields above must be
|
||||
present in the sanitized `/api/tour_pages` response. If, for example,
|
||||
`background_video_start_time` is removed, the frontend correctly applies its
|
||||
`null` default and briefly renders the file's first frame instead of seeking to
|
||||
the configured time.
|
||||
|
||||
**Session-Scoped Play Once Behavior (when loop=false):**
|
||||
When loop is disabled, videos only play once per browser session:
|
||||
1. Video plays normally on first navigation to the page
|
||||
|
||||
@ -770,7 +770,6 @@ interface UseBackgroundAudioPlaybackResult {
|
||||
```typescript
|
||||
const { audioRef } = useBackgroundAudioPlayback({
|
||||
audioUrl: page.background_audio_url,
|
||||
autoplay: page.background_audio_autoplay ?? true,
|
||||
loop: page.background_audio_loop ?? true,
|
||||
startTime: page.background_audio_start_time ?? null,
|
||||
endTime: page.background_audio_end_time ?? null,
|
||||
@ -1019,6 +1018,8 @@ const result = usePWAPreload({
|
||||
});
|
||||
```
|
||||
|
||||
Playback begins through the runtime's browser interaction/unlock flow.
|
||||
|
||||
---
|
||||
|
||||
### 3. Constructor Hooks
|
||||
|
||||
@ -280,7 +280,11 @@ The `runtime-public.ts` middleware sanitizes responses for unauthenticated reque
|
||||
|
||||
**Projects:** id, name, slug, description, logo_url, favicon_url, og_image_url
|
||||
|
||||
**Tour Pages:** id, projectId, environment, source_key, name, slug, sort_order, background_image_url, background_video_url, background_embed_url, background_audio_url, background_loop, requires_auth, ui_schema_json
|
||||
**Tour Pages:** id, projectId, environment, source_key, name, slug, sort_order,
|
||||
background image/video/embed/audio URLs, background audio loop/start/end,
|
||||
background video autoplay/loop/muted/start/end, design width/height,
|
||||
background_loop, requires_auth, ui_schema_json, and
|
||||
global_ui_controls_settings_json.
|
||||
|
||||
**Project Audio Tracks:** id, projectId, environment, source_key, name, slug, url, loop, volume, sort_order, is_enabled
|
||||
|
||||
@ -942,6 +946,13 @@ Background videos support configurable playback settings including custom start/
|
||||
| `background_video_start_time` | DECIMAL(10,1) | null | Start time in seconds |
|
||||
| `background_video_end_time` | DECIMAL(10,1) | null | End/loop time in seconds |
|
||||
|
||||
Public production runtime responses retain the page-level video settings,
|
||||
background-audio loop/start/end settings, and page
|
||||
`design_width`/`design_height` values. These fields must remain in the backend
|
||||
public-runtime allowlist; otherwise the frontend falls back to default playback
|
||||
behavior (for example, a missing video start time is treated as `null` and
|
||||
playback begins at frame zero).
|
||||
|
||||
**DECIMAL Parsing (Critical):**
|
||||
|
||||
Sequelize DECIMAL fields return strings from the database (e.g., `"2.5"` not `2.5`). These must be parsed before use:
|
||||
|
||||
@ -149,7 +149,6 @@ export interface TourPage extends BaseEntity {
|
||||
background_video_start_time?: number | null;
|
||||
background_video_end_time?: number | null;
|
||||
// Background audio playback settings
|
||||
background_audio_autoplay?: boolean;
|
||||
background_audio_loop?: boolean;
|
||||
background_audio_start_time?: number | null;
|
||||
background_audio_end_time?: number | null;
|
||||
@ -513,7 +512,6 @@ export interface RuntimePage extends PreloadPage {
|
||||
background_video_start_time?: number | null;
|
||||
background_video_end_time?: number | null;
|
||||
// Background audio playback settings
|
||||
background_audio_autoplay?: boolean;
|
||||
background_audio_loop?: boolean;
|
||||
background_audio_start_time?: number | null;
|
||||
background_audio_end_time?: number | null;
|
||||
|
||||
@ -59,7 +59,6 @@ export interface TourPage {
|
||||
background_video_start_time?: number | null;
|
||||
background_video_end_time?: number | null;
|
||||
// Background audio playback settings
|
||||
background_audio_autoplay?: boolean;
|
||||
background_audio_loop?: boolean;
|
||||
background_audio_start_time?: number | null;
|
||||
background_audio_end_time?: number | null;
|
||||
|
||||
@ -37,7 +37,6 @@ interface TourPageData {
|
||||
background_video_muted?: boolean;
|
||||
background_video_start_time?: number | null;
|
||||
background_video_end_time?: number | null;
|
||||
background_audio_autoplay?: boolean;
|
||||
background_audio_loop?: boolean;
|
||||
background_audio_start_time?: number | null;
|
||||
background_audio_end_time?: number | null;
|
||||
|
||||
@ -138,7 +138,6 @@ export interface TourPage extends BaseEntity {
|
||||
background_video_start_time?: number | null;
|
||||
background_video_end_time?: number | null;
|
||||
// Background audio playback settings
|
||||
background_audio_autoplay?: boolean;
|
||||
background_audio_loop?: boolean;
|
||||
background_audio_start_time?: number | null;
|
||||
background_audio_end_time?: number | null;
|
||||
|
||||
@ -42,7 +42,6 @@ export interface RuntimePage extends PreloadPage {
|
||||
background_video_start_time?: number | null;
|
||||
background_video_end_time?: number | null;
|
||||
// Background audio playback settings
|
||||
background_audio_autoplay?: boolean;
|
||||
background_audio_loop?: boolean;
|
||||
background_audio_start_time?: number | null;
|
||||
background_audio_end_time?: number | null;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user