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`
|
**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
|
### POST /api/projects
|
||||||
|
|
||||||
@ -647,7 +648,11 @@ Get PWA offline manifest for a project.
|
|||||||
|
|
||||||
**Permissions**: `CREATE_TOUR_PAGES`, `READ_TOUR_PAGES`, etc.
|
**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
|
### POST /api/tour_pages/reorder
|
||||||
|
|
||||||
@ -753,8 +758,18 @@ Duplicates a constructor/dev page into a new independent dev page.
|
|||||||
"sort_order": 1,
|
"sort_order": 1,
|
||||||
"background_image_url": "assets/bg.jpg",
|
"background_image_url": "assets/bg.jpg",
|
||||||
"background_video_url": null,
|
"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_url": "assets/ambient.mp3",
|
||||||
|
"background_audio_loop": true,
|
||||||
|
"background_audio_start_time": 1.5,
|
||||||
|
"background_audio_end_time": 12,
|
||||||
"background_loop": true,
|
"background_loop": true,
|
||||||
|
"design_width": 1920,
|
||||||
|
"design_height": 1080,
|
||||||
"requires_auth": false,
|
"requires_auth": false,
|
||||||
"ui_schema_json": {
|
"ui_schema_json": {
|
||||||
"elements": [],
|
"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_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_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_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_loop` | BOOLEAN | NOT NULL, default: true | Loop background audio |
|
||||||
| `background_audio_start_time` | DECIMAL(10,1) | nullable | Background audio start time (seconds) |
|
| `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) |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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/`
|
**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
|
**Migration safety policy:** Do not rewrite, rename, or reformat already
|
||||||
applied migration files. Production databases track migration names in
|
applied migration files. Production databases track migration names in
|
||||||
@ -53,7 +53,10 @@ backend/
|
|||||||
├── 20260626000001-*.js # Private production presentation access
|
├── 20260626000001-*.js # Private production presentation access
|
||||||
├── 20260626000002-*.js # Account manager user creation permission
|
├── 20260626000002-*.js # Account manager user creation permission
|
||||||
├── 20260628000001-*.js # Global UI-control settings tables
|
├── 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-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-projects` | Add `design_width`, `design_height` to projects table |
|
||||||
| `add-design-dimensions-to-tour-pages` | Add `design_width`, `design_height` to tour_pages 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
|
### Table Renames
|
||||||
|
|
||||||
@ -823,7 +827,21 @@ explicit rollback/backup plan.
|
|||||||
| 21 | 20260403000001 | add-background-video-settings | Column |
|
| 21 | 20260403000001 | add-background-video-settings | Column |
|
||||||
| 22 | 20260409000001 | add-design-dimensions-to-projects | Column |
|
| 22 | 20260409000001 | add-design-dimensions-to-projects | Column |
|
||||||
| 23 | 20260409111309 | add-design-dimensions-to-tour-pages | 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 |
|
| PWA Caches | 3 | Offline cache configs |
|
||||||
| Access Logs | 3 | Visitor tracking |
|
| 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
|
#### Sample Projects
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
|
|||||||
@ -441,6 +441,7 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
|
|||||||
'logo_url',
|
'logo_url',
|
||||||
'favicon_url',
|
'favicon_url',
|
||||||
'og_image_url',
|
'og_image_url',
|
||||||
|
'production_presentation_visibility',
|
||||||
],
|
],
|
||||||
tour_pages: [
|
tour_pages: [
|
||||||
'id',
|
'id',
|
||||||
@ -452,10 +453,22 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
|
|||||||
'sort_order',
|
'sort_order',
|
||||||
'background_image_url',
|
'background_image_url',
|
||||||
'background_video_url',
|
'background_video_url',
|
||||||
|
'background_embed_url',
|
||||||
'background_audio_url',
|
'background_audio_url',
|
||||||
|
'background_audio_loop',
|
||||||
|
'background_audio_start_time',
|
||||||
|
'background_audio_end_time',
|
||||||
'background_loop',
|
'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',
|
'requires_auth',
|
||||||
'ui_schema_json',
|
'ui_schema_json',
|
||||||
|
'global_ui_controls_settings_json',
|
||||||
],
|
],
|
||||||
project_audio_tracks: [
|
project_audio_tracks: [
|
||||||
'id',
|
'id',
|
||||||
@ -470,9 +483,28 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
|
|||||||
'sort_order',
|
'sort_order',
|
||||||
'is_enabled',
|
'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
|
#### Function: blockNonPublicRuntimeListEndpoints
|
||||||
|
|
||||||
Restricts public runtime requests to list endpoints only.
|
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:**
|
**Schema Fields:**
|
||||||
|
|
||||||
- `source_key`, `name`, `slug`
|
- `source_key`, `name`, `slug`
|
||||||
- `background_image_url`, `background_video_url`, `background_audio_url`
|
- `background_image_url`, `background_video_url`, `background_embed_url`,
|
||||||
- `ui_schema_json`, `sort_order`
|
`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:**
|
**Endpoints:**
|
||||||
|
|
||||||
|
|||||||
@ -166,7 +166,6 @@ class Tour_pagesDBApi extends GenericDBApi {
|
|||||||
background_video_url: data.background_video_url || null,
|
background_video_url: data.background_video_url || null,
|
||||||
background_embed_url: data.background_embed_url || null,
|
background_embed_url: data.background_embed_url || null,
|
||||||
background_audio_url: data.background_audio_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_loop: data.background_audio_loop ?? true,
|
||||||
background_audio_start_time: data.background_audio_start_time ?? null,
|
background_audio_start_time: data.background_audio_start_time ?? null,
|
||||||
background_audio_end_time: data.background_audio_end_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,
|
type: DataTypes.TEXT,
|
||||||
},
|
},
|
||||||
|
|
||||||
background_audio_autoplay: {
|
|
||||||
type: DataTypes.BOOLEAN,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
background_audio_loop: {
|
background_audio_loop: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
@ -344,8 +344,28 @@ const TourPagesData = [
|
|||||||
background_video_url:
|
background_video_url:
|
||||||
'https://cdn.platform.com/cardiff/video/floor-loop.mp4',
|
'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_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,
|
background_loop: true,
|
||||||
|
|
||||||
requires_auth: true,
|
requires_auth: true,
|
||||||
|
|||||||
@ -33,7 +33,17 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS: RuntimePublicFieldMap = {
|
|||||||
'background_video_url',
|
'background_video_url',
|
||||||
'background_embed_url',
|
'background_embed_url',
|
||||||
'background_audio_url',
|
'background_audio_url',
|
||||||
|
'background_audio_loop',
|
||||||
|
'background_audio_start_time',
|
||||||
|
'background_audio_end_time',
|
||||||
'background_loop',
|
'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',
|
'requires_auth',
|
||||||
'ui_schema_json',
|
'ui_schema_json',
|
||||||
'global_ui_controls_settings_json',
|
'global_ui_controls_settings_json',
|
||||||
|
|||||||
@ -16,6 +16,7 @@ interface CrudResource {
|
|||||||
schema: string;
|
schema: string;
|
||||||
description: string;
|
description: string;
|
||||||
runtimePublicRead?: boolean;
|
runtimePublicRead?: boolean;
|
||||||
|
runtimePublicReadDescription?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OpenApiDocument {
|
interface OpenApiDocument {
|
||||||
@ -273,8 +274,11 @@ const crudPaths = (resource: CrudResource): OpenApiPaths => {
|
|||||||
const readSecurity = resource.runtimePublicRead
|
const readSecurity = resource.runtimePublicRead
|
||||||
? [{ bearerAuth: [] }, {}]
|
? [{ bearerAuth: [] }, {}]
|
||||||
: bearerSecurity;
|
: bearerSecurity;
|
||||||
|
const runtimePublicReadDescription = resource.runtimePublicReadDescription
|
||||||
|
? ` ${resource.runtimePublicReadDescription}`
|
||||||
|
: '';
|
||||||
const readDescription = resource.runtimePublicRead
|
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;
|
: resource.description;
|
||||||
const commonReadParameters = resource.runtimePublicRead
|
const commonReadParameters = resource.runtimePublicRead
|
||||||
? [...runtimeHeaders]
|
? [...runtimeHeaders]
|
||||||
@ -600,7 +604,6 @@ const schemas: Record<string, OpenApiSchema> = {
|
|||||||
background_video_url: nullable({ type: 'string', maxLength: 4096 }),
|
background_video_url: nullable({ type: 'string', maxLength: 4096 }),
|
||||||
background_embed_url: nullable({ type: 'string', maxLength: 8192 }),
|
background_embed_url: nullable({ type: 'string', maxLength: 8192 }),
|
||||||
background_audio_url: nullable({ type: 'string', maxLength: 4096 }),
|
background_audio_url: nullable({ type: 'string', maxLength: 4096 }),
|
||||||
background_audio_autoplay: { type: 'boolean' },
|
|
||||||
background_audio_loop: { type: 'boolean' },
|
background_audio_loop: { type: 'boolean' },
|
||||||
background_audio_start_time: nullable({ type: 'number', minimum: 0 }),
|
background_audio_start_time: nullable({ type: 'number', minimum: 0 }),
|
||||||
background_audio_end_time: nullable({ type: 'number', minimum: 0 }),
|
background_audio_end_time: nullable({ type: 'number', minimum: 0 }),
|
||||||
@ -1088,6 +1091,8 @@ const crudResources: CrudResource[] = [
|
|||||||
schema: 'TourPage',
|
schema: 'TourPage',
|
||||||
description: 'tour pages',
|
description: 'tour pages',
|
||||||
runtimePublicRead: true,
|
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',
|
path: '/api/assets',
|
||||||
|
|||||||
@ -658,7 +658,6 @@ class TourPagesService extends BaseService {
|
|||||||
background_video_url: source.background_video_url || '',
|
background_video_url: source.background_video_url || '',
|
||||||
background_embed_url: source.background_embed_url || '',
|
background_embed_url: source.background_embed_url || '',
|
||||||
background_audio_url: source.background_audio_url || '',
|
background_audio_url: source.background_audio_url || '',
|
||||||
background_audio_autoplay: source.background_audio_autoplay,
|
|
||||||
background_audio_loop: source.background_audio_loop,
|
background_audio_loop: source.background_audio_loop,
|
||||||
background_audio_start_time: source.background_audio_start_time,
|
background_audio_start_time: source.background_audio_start_time,
|
||||||
background_audio_end_time: source.background_audio_end_time,
|
background_audio_end_time: source.background_audio_end_time,
|
||||||
|
|||||||
@ -90,7 +90,6 @@ export interface TourPageData {
|
|||||||
background_video_url?: string | null | undefined;
|
background_video_url?: string | null | undefined;
|
||||||
background_embed_url?: string | null | undefined;
|
background_embed_url?: string | null | undefined;
|
||||||
background_audio_url?: string | null | undefined;
|
background_audio_url?: string | null | undefined;
|
||||||
background_audio_autoplay?: boolean | undefined;
|
|
||||||
background_audio_loop?: boolean | undefined;
|
background_audio_loop?: boolean | undefined;
|
||||||
background_audio_start_time?: number | null | undefined;
|
background_audio_start_time?: number | null | undefined;
|
||||||
background_audio_end_time?: number | null | undefined;
|
background_audio_end_time?: number | null | undefined;
|
||||||
@ -264,7 +263,6 @@ export interface TourPageFieldMapping {
|
|||||||
background_video_url: string | null;
|
background_video_url: string | null;
|
||||||
background_embed_url: string | null;
|
background_embed_url: string | null;
|
||||||
background_audio_url: string | null;
|
background_audio_url: string | null;
|
||||||
background_audio_autoplay: boolean;
|
|
||||||
background_audio_loop: boolean;
|
background_audio_loop: boolean;
|
||||||
background_audio_start_time: number | null;
|
background_audio_start_time: number | null;
|
||||||
background_audio_end_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_video_url: Joi.string().allow('', null).max(4096),
|
||||||
background_embed_url: Joi.string().allow('', null).max(8192),
|
background_embed_url: Joi.string().allow('', null).max(8192),
|
||||||
background_audio_url: Joi.string().allow('', null).max(4096),
|
background_audio_url: Joi.string().allow('', null).max(4096),
|
||||||
background_audio_autoplay: Joi.boolean(),
|
|
||||||
background_audio_loop: Joi.boolean(),
|
background_audio_loop: Joi.boolean(),
|
||||||
background_audio_start_time: Joi.number().min(0).allow(null),
|
background_audio_start_time: Joi.number().min(0).allow(null),
|
||||||
background_audio_end_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;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
sortOrder: number;
|
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) {
|
function createTourPage(data: TourPageSeed, transaction: Transaction) {
|
||||||
@ -106,18 +116,17 @@ function createTourPage(data: TourPageSeed, transaction: Transaction) {
|
|||||||
background_video_url: null,
|
background_video_url: null,
|
||||||
background_embed_url: null,
|
background_embed_url: null,
|
||||||
background_audio_url: null,
|
background_audio_url: null,
|
||||||
background_audio_autoplay: true,
|
background_audio_loop: data.backgroundAudioLoop ?? true,
|
||||||
background_audio_loop: true,
|
background_audio_start_time: data.backgroundAudioStartTime ?? null,
|
||||||
background_audio_start_time: null,
|
background_audio_end_time: data.backgroundAudioEndTime ?? null,
|
||||||
background_audio_end_time: null,
|
|
||||||
background_loop: false,
|
background_loop: false,
|
||||||
background_video_autoplay: true,
|
background_video_autoplay: data.backgroundVideoAutoplay ?? true,
|
||||||
background_video_loop: true,
|
background_video_loop: data.backgroundVideoLoop ?? true,
|
||||||
background_video_muted: true,
|
background_video_muted: data.backgroundVideoMuted ?? true,
|
||||||
background_video_start_time: null,
|
background_video_start_time: data.backgroundVideoStartTime ?? null,
|
||||||
background_video_end_time: null,
|
background_video_end_time: data.backgroundVideoEndTime ?? null,
|
||||||
design_width: null,
|
design_width: data.designWidth ?? null,
|
||||||
design_height: null,
|
design_height: data.designHeight ?? null,
|
||||||
requires_auth: false,
|
requires_auth: false,
|
||||||
ui_schema_json: {
|
ui_schema_json: {
|
||||||
elements: [{ id: `${data.slug}-button`, type: 'button' }],
|
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',
|
name: 'Dev Lobby',
|
||||||
slug: 'lobby',
|
slug: 'lobby',
|
||||||
sortOrder: 1,
|
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,
|
transaction,
|
||||||
);
|
);
|
||||||
@ -357,6 +376,16 @@ void test('copyDevToStage replaces stage content with dev pages, audio, and sett
|
|||||||
assert.deepEqual(copiedPage.ui_schema_json, {
|
assert.deepEqual(copiedPage.ui_schema_json, {
|
||||||
elements: [{ id: 'lobby-button', type: 'button' }],
|
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({
|
const stageAudios = await db.project_audio_tracks.findAll({
|
||||||
where: { projectId: project.id, environment: 'stage' },
|
where: { projectId: project.id, environment: 'stage' },
|
||||||
@ -405,6 +434,16 @@ void test('copyStageToProduction leaves stage content intact while replacing pro
|
|||||||
name: 'Stage Intro',
|
name: 'Stage Intro',
|
||||||
slug: 'intro',
|
slug: 'intro',
|
||||||
sortOrder: 1,
|
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,
|
transaction,
|
||||||
);
|
);
|
||||||
@ -446,5 +485,15 @@ void test('copyStageToProduction leaves stage content intact while replacing pro
|
|||||||
assert.ok(productionPage);
|
assert.ok(productionPage);
|
||||||
assert.notEqual(productionPage.id, sourcePage.id);
|
assert.notEqual(productionPage.id, sourcePage.id);
|
||||||
assert.equal(productionPage.source_key, 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', () => {
|
void test('OpenAPI factory CRUD paths are generated consistently', () => {
|
||||||
const document = createTestDocument();
|
const document = createTestDocument();
|
||||||
const resourcePath = '/api/assets';
|
const resourcePath = '/api/assets';
|
||||||
|
|||||||
@ -120,6 +120,18 @@ void test('sanitizePublicRuntimeListResponse strips non-public fields from rows
|
|||||||
count: 1,
|
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({
|
const recordReq = createRequest({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/',
|
url: '/',
|
||||||
@ -135,6 +147,7 @@ void test('sanitizePublicRuntimeListResponse strips non-public fields from rows
|
|||||||
recordRes.send({
|
recordRes.send({
|
||||||
id: 'page-1',
|
id: 'page-1',
|
||||||
slug: 'intro',
|
slug: 'intro',
|
||||||
|
...runtimePagePresentationFields,
|
||||||
ui_schema_json: { elements: [] },
|
ui_schema_json: { elements: [] },
|
||||||
internal_note: 'do-not-leak',
|
internal_note: 'do-not-leak',
|
||||||
});
|
});
|
||||||
@ -142,6 +155,7 @@ void test('sanitizePublicRuntimeListResponse strips non-public fields from rows
|
|||||||
assert.deepEqual(recordRes._getData(), {
|
assert.deepEqual(recordRes._getData(), {
|
||||||
id: 'page-1',
|
id: 'page-1',
|
||||||
slug: 'intro',
|
slug: 'intro',
|
||||||
|
...runtimePagePresentationFields,
|
||||||
ui_schema_json: { elements: [] },
|
ui_schema_json: { elements: [] },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -684,6 +684,17 @@ Create a tour page.
|
|||||||
"environment": "dev",
|
"environment": "dev",
|
||||||
"background_image_url": "https://...",
|
"background_image_url": "https://...",
|
||||||
"background_video_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": {}
|
"ui_schema_json": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -699,6 +710,12 @@ List tour pages.
|
|||||||
- `projectId` - Filter by project
|
- `projectId` - Filter by project
|
||||||
- `environment` - Filter by environment
|
- `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 /api/tour_pages/:id
|
||||||
|
|
||||||
Get tour page by 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
|
// Using useBackgroundAudioPlayback hook for controlled playback
|
||||||
const { audioRef } = useBackgroundAudioPlayback({
|
const { audioRef } = useBackgroundAudioPlayback({
|
||||||
audioUrl: page.background_audio_url,
|
audioUrl: page.background_audio_url,
|
||||||
autoplay: page.background_audio_autoplay ?? true,
|
|
||||||
loop: page.background_audio_loop ?? true,
|
loop: page.background_audio_loop ?? true,
|
||||||
startTime: page.background_audio_start_time ?? null,
|
startTime: page.background_audio_start_time ?? null,
|
||||||
endTime: page.background_audio_end_time ?? null,
|
endTime: page.background_audio_end_time ?? null,
|
||||||
@ -61,7 +60,6 @@ const { audioRef } = useBackgroundAudioPlayback({
|
|||||||
|
|
||||||
| Property | Type | Default | Description |
|
| Property | Type | Default | Description |
|
||||||
|----------|------|---------|-------------|
|
|----------|------|---------|-------------|
|
||||||
| `background_audio_autoplay` | boolean | true | Start playback automatically |
|
|
||||||
| `background_audio_loop` | boolean | true | Loop continuously |
|
| `background_audio_loop` | boolean | true | Loop continuously |
|
||||||
| `background_audio_start_time` | DECIMAL(10,1) \| null | null | Start playback at this time (seconds) |
|
| `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) |
|
| `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`.
|
**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
|
### 2. Element Audio Effects
|
||||||
|
|
||||||
Interactive audio triggered by user hover and click on UI elements.
|
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}
|
value={backgroundAudioUrl}
|
||||||
options={audioAssetOptions}
|
options={audioAssetOptions}
|
||||||
onChange={setBackgroundAudioUrl}
|
onChange={setBackgroundAudioUrl}
|
||||||
audioAutoplay={pageBackground.audioSettings.autoplay}
|
|
||||||
audioLoop={pageBackground.audioSettings.loop}
|
audioLoop={pageBackground.audioSettings.loop}
|
||||||
audioStartTime={pageBackground.audioSettings.startTime}
|
audioStartTime={pageBackground.audioSettings.startTime}
|
||||||
audioEndTime={pageBackground.audioSettings.endTime}
|
audioEndTime={pageBackground.audioSettings.endTime}
|
||||||
@ -304,7 +304,6 @@ The Constructor provides UI controls for background audio settings via `Backgrou
|
|||||||
|
|
||||||
| Setting | Control | Description |
|
| Setting | Control | Description |
|
||||||
|---------|---------|-------------|
|
|---------|---------|-------------|
|
||||||
| Autoplay | Checkbox | Start audio automatically on page load |
|
|
||||||
| Loop | Checkbox | Continuously loop audio |
|
| Loop | Checkbox | Continuously loop audio |
|
||||||
| Start Time | Number input | Begin playback at specific time (seconds) |
|
| Start Time | Number input | Begin playback at specific time (seconds) |
|
||||||
| End Time | Number input | Stop/loop 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;
|
const backgroundAudioUrl = navCurrentBgAudioUrl;
|
||||||
|
|
||||||
// Extract settings from selected page
|
// Extract settings from selected page
|
||||||
const audioAutoplay = selectedPage?.background_audio_autoplay ?? true;
|
|
||||||
const audioLoop = selectedPage?.background_audio_loop ?? true;
|
const audioLoop = selectedPage?.background_audio_loop ?? true;
|
||||||
const audioStartTime = selectedPage?.background_audio_start_time != null
|
const audioStartTime = selectedPage?.background_audio_start_time != null
|
||||||
? parseFloat(String(selectedPage.background_audio_start_time))
|
? parseFloat(String(selectedPage.background_audio_start_time))
|
||||||
@ -336,7 +334,6 @@ const audioEndTime = selectedPage?.background_audio_end_time != null
|
|||||||
// Pass to CanvasBackground
|
// Pass to CanvasBackground
|
||||||
<CanvasBackground
|
<CanvasBackground
|
||||||
backgroundAudioUrl={backgroundAudioUrl}
|
backgroundAudioUrl={backgroundAudioUrl}
|
||||||
audioAutoplay={audioAutoplay}
|
|
||||||
audioLoop={audioLoop}
|
audioLoop={audioLoop}
|
||||||
audioStartTime={audioStartTime}
|
audioStartTime={audioStartTime}
|
||||||
audioEndTime={audioEndTime}
|
audioEndTime={audioEndTime}
|
||||||
@ -352,7 +349,6 @@ const audioEndTime = selectedPage?.background_audio_end_time != null
|
|||||||
const { audioRef } = useBackgroundAudioPlayback({
|
const { audioRef } = useBackgroundAudioPlayback({
|
||||||
audioUrl: backgroundAudioUrl,
|
audioUrl: backgroundAudioUrl,
|
||||||
audioStoragePath,
|
audioStoragePath,
|
||||||
autoplay: audioAutoplay,
|
|
||||||
loop: audioLoop,
|
loop: audioLoop,
|
||||||
startTime: audioStartTime,
|
startTime: audioStartTime,
|
||||||
endTime: audioEndTime,
|
endTime: audioEndTime,
|
||||||
@ -382,7 +378,6 @@ Manages background audio playback with start/end time control and ducking integr
|
|||||||
interface UseBackgroundAudioPlaybackOptions {
|
interface UseBackgroundAudioPlaybackOptions {
|
||||||
audioUrl?: string; // Audio URL (may be blob URL)
|
audioUrl?: string; // Audio URL (may be blob URL)
|
||||||
audioStoragePath?: string; // Original storage path for play-once tracking
|
audioStoragePath?: string; // Original storage path for play-once tracking
|
||||||
autoplay?: boolean; // Default: true
|
|
||||||
loop?: boolean; // Default: true
|
loop?: boolean; // Default: true
|
||||||
startTime?: number | null; // Start at time (seconds)
|
startTime?: number | null; // Start at time (seconds)
|
||||||
endTime?: number | null; // Stop/loop 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:
|
Audio settings are stored in the `tour_pages` table:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- Added via migration 20260605000001-add-background-audio-settings.js
|
background_audio_loop BOOLEAN NOT NULL DEFAULT true
|
||||||
ALTER TABLE tour_pages
|
background_audio_start_time DECIMAL(10, 1) NULL
|
||||||
ADD COLUMN background_audio_autoplay BOOLEAN NOT NULL DEFAULT true,
|
background_audio_end_time DECIMAL(10, 1) NULL
|
||||||
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);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## Key Files Reference
|
||||||
|
|||||||
@ -592,10 +592,21 @@ All entities include:
|
|||||||
projectId, environment,
|
projectId, environment,
|
||||||
background_image_url,
|
background_image_url,
|
||||||
background_video_url,
|
background_video_url,
|
||||||
|
background_embed_url,
|
||||||
background_audio_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,
|
background_loop,
|
||||||
|
design_width, design_height,
|
||||||
requires_auth,
|
requires_auth,
|
||||||
ui_schema_json, // Page layout/elements
|
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`.
|
**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):**
|
**Session-Scoped Play Once Behavior (when loop=false):**
|
||||||
When loop is disabled, videos only play once per browser session:
|
When loop is disabled, videos only play once per browser session:
|
||||||
1. Video plays normally on first navigation to the page
|
1. Video plays normally on first navigation to the page
|
||||||
|
|||||||
@ -770,7 +770,6 @@ interface UseBackgroundAudioPlaybackResult {
|
|||||||
```typescript
|
```typescript
|
||||||
const { audioRef } = useBackgroundAudioPlayback({
|
const { audioRef } = useBackgroundAudioPlayback({
|
||||||
audioUrl: page.background_audio_url,
|
audioUrl: page.background_audio_url,
|
||||||
autoplay: page.background_audio_autoplay ?? true,
|
|
||||||
loop: page.background_audio_loop ?? true,
|
loop: page.background_audio_loop ?? true,
|
||||||
startTime: page.background_audio_start_time ?? null,
|
startTime: page.background_audio_start_time ?? null,
|
||||||
endTime: page.background_audio_end_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
|
### 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
|
**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
|
**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_start_time` | DECIMAL(10,1) | null | Start time in seconds |
|
||||||
| `background_video_end_time` | DECIMAL(10,1) | null | End/loop 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):**
|
**DECIMAL Parsing (Critical):**
|
||||||
|
|
||||||
Sequelize DECIMAL fields return strings from the database (e.g., `"2.5"` not `2.5`). These must be parsed before use:
|
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_start_time?: number | null;
|
||||||
background_video_end_time?: number | null;
|
background_video_end_time?: number | null;
|
||||||
// Background audio playback settings
|
// Background audio playback settings
|
||||||
background_audio_autoplay?: boolean;
|
|
||||||
background_audio_loop?: boolean;
|
background_audio_loop?: boolean;
|
||||||
background_audio_start_time?: number | null;
|
background_audio_start_time?: number | null;
|
||||||
background_audio_end_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_start_time?: number | null;
|
||||||
background_video_end_time?: number | null;
|
background_video_end_time?: number | null;
|
||||||
// Background audio playback settings
|
// Background audio playback settings
|
||||||
background_audio_autoplay?: boolean;
|
|
||||||
background_audio_loop?: boolean;
|
background_audio_loop?: boolean;
|
||||||
background_audio_start_time?: number | null;
|
background_audio_start_time?: number | null;
|
||||||
background_audio_end_time?: number | null;
|
background_audio_end_time?: number | null;
|
||||||
|
|||||||
@ -59,7 +59,6 @@ export interface TourPage {
|
|||||||
background_video_start_time?: number | null;
|
background_video_start_time?: number | null;
|
||||||
background_video_end_time?: number | null;
|
background_video_end_time?: number | null;
|
||||||
// Background audio playback settings
|
// Background audio playback settings
|
||||||
background_audio_autoplay?: boolean;
|
|
||||||
background_audio_loop?: boolean;
|
background_audio_loop?: boolean;
|
||||||
background_audio_start_time?: number | null;
|
background_audio_start_time?: number | null;
|
||||||
background_audio_end_time?: number | null;
|
background_audio_end_time?: number | null;
|
||||||
|
|||||||
@ -37,7 +37,6 @@ interface TourPageData {
|
|||||||
background_video_muted?: boolean;
|
background_video_muted?: boolean;
|
||||||
background_video_start_time?: number | null;
|
background_video_start_time?: number | null;
|
||||||
background_video_end_time?: number | null;
|
background_video_end_time?: number | null;
|
||||||
background_audio_autoplay?: boolean;
|
|
||||||
background_audio_loop?: boolean;
|
background_audio_loop?: boolean;
|
||||||
background_audio_start_time?: number | null;
|
background_audio_start_time?: number | null;
|
||||||
background_audio_end_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_start_time?: number | null;
|
||||||
background_video_end_time?: number | null;
|
background_video_end_time?: number | null;
|
||||||
// Background audio playback settings
|
// Background audio playback settings
|
||||||
background_audio_autoplay?: boolean;
|
|
||||||
background_audio_loop?: boolean;
|
background_audio_loop?: boolean;
|
||||||
background_audio_start_time?: number | null;
|
background_audio_start_time?: number | null;
|
||||||
background_audio_end_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_start_time?: number | null;
|
||||||
background_video_end_time?: number | null;
|
background_video_end_time?: number | null;
|
||||||
// Background audio playback settings
|
// Background audio playback settings
|
||||||
background_audio_autoplay?: boolean;
|
|
||||||
background_audio_loop?: boolean;
|
background_audio_loop?: boolean;
|
||||||
background_audio_start_time?: number | null;
|
background_audio_start_time?: number | null;
|
||||||
background_audio_end_time?: number | null;
|
background_audio_end_time?: number | null;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user