improved video and audio playback functionality

This commit is contained in:
Dmitri 2026-07-31 12:42:23 +02:00
parent 80fe56c6f9
commit 7080a33177
31 changed files with 414 additions and 54 deletions

View File

@ -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": [],

View File

@ -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 |
---

View File

@ -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 |
---

View File

@ -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

View File

@ -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.

View File

@ -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:**

View File

@ -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,

View File

@ -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,
});
},
};

View File

@ -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,

View File

@ -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,

View File

@ -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',

View File

@ -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',

View File

@ -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,

View File

@ -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;

View File

@ -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),

View 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,
},
},
]);
});

View 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'));
});

View File

@ -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);
});
});

View File

@ -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';

View File

@ -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: [] },
});
});

View File

@ -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.

View File

@ -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

View File

@ -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,
}
```

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;