diff --git a/lib/osh-js/source/core/ui/view/video/FFMPEG360View.js b/lib/osh-js/source/core/ui/view/video/FFMPEG360View.js
new file mode 100644
index 00000000..e4e9bf17
--- /dev/null
+++ b/lib/osh-js/source/core/ui/view/video/FFMPEG360View.js
@@ -0,0 +1,32 @@
+import FFMPEGView from './FFMPEGView';
+import YUV360Canvas from './YUV360Canvas';
+
+class FFMPEG360View extends FFMPEGView {
+ constructor(properties) {
+ super(properties);
+ this.fisheyeFovDeg = properties?.props360?.fisheyeFovDeg ?? 204;
+ this.projection = properties?.props360?.projection ?? 'equirectangular';
+ this.canvas360.setProjection(this.projection);
+ this.canvas360.setFisheyeFov(this.fisheyeFovDeg);
+ }
+
+ get canvas360() {
+ return /** @type {YUV360Canvas} */ (this.yuvCanvas);
+ }
+
+ createCanvas(width, height, style){
+ return new YUV360Canvas({width: width, height: height, fisheyeFovDeg: this.fisheyeFovDeg, projection: this.projection, contextOptions: {preserveDrawingBuffer: true}});
+ }
+
+ setFisheyeFov(fullFovDeg) {
+ this.fisheyeFovDeg = fullFovDeg;
+ if (this.yuvCanvas) this.canvas360.setFisheyeFov(fullFovDeg);
+ }
+
+ setProjection(projection) {
+ this.projection = projection;
+ if (this.yuvCanvas) this.canvas360.setProjection(projection);
+ }
+}
+
+export default FFMPEG360View;
\ No newline at end of file
diff --git a/lib/osh-js/source/core/ui/view/video/VideoView.js b/lib/osh-js/source/core/ui/view/video/VideoView.js
index 0ea193d6..817db5c0 100644
--- a/lib/osh-js/source/core/ui/view/video/VideoView.js
+++ b/lib/osh-js/source/core/ui/view/video/VideoView.js
@@ -1,6 +1,7 @@
import MjpegView from "./MjpegView";
import WebCodecView from "./WebCodecView";
import FFMPEGView from "./FFMPEGView";
+import FFMPEG360View from "/lib/osh-js/source/core/ui/view/video/FFMPEG360View";
import View from "../View";
import {isDefined} from "../../../utils/Utils";
@@ -29,6 +30,7 @@ class VideoView extends View {
* @param {Boolean} [properties.directPlay=false] - Enable or ignore the framerate play
* @param {Boolean} [properties.showTime=false] - Enable or ignore the show timestamp text onto the canvas
* @param {Boolean} [properties.showStats=false] - Enable or ignore the display stats (FPS number) onto the canvas
+ * @param {Object} [properties.props360={}] - Enable interactive 360 display
* @param {Number} [properties.width=1920] - Set the default canvas width
* @param {Number} [properties.height=1080] - Set the default canvas height
* @param {Number} [properties.useWebCodecApi=true] - Use experimental WebCodecApi
@@ -41,13 +43,22 @@ class VideoView extends View {
this.videoView = undefined;
this.canvasResolve = undefined;
this.useWebCodecApi = true;
+ if('props360' in properties) {
+ this.props360 = properties['props360'];
+ }
if('useWebCodecApi' in properties) {
this.useWebCodecApi = properties['useWebCodecApi'];
}
}
createVideoView(compression) {
- if(compression === 'jpeg') {
+ if (this.props360 != null) {
+ this.videoView = new FFMPEG360View({
+ ...this.properties,
+ codec: compression,
+ layers: []
+ });
+ } else if(compression === 'jpeg') {
// create MJPEG View
this.videoView = new MjpegView({
...this.properties,
diff --git a/lib/osh-js/source/core/ui/view/video/YUV360Canvas.js b/lib/osh-js/source/core/ui/view/video/YUV360Canvas.js
new file mode 100644
index 00000000..7b01d6fa
--- /dev/null
+++ b/lib/osh-js/source/core/ui/view/video/YUV360Canvas.js
@@ -0,0 +1,606 @@
+import YUVCanvas from './YUVCanvas';
+
+export const Projection = Object.freeze({
+ EQUIRECTANGULAR : 0,
+ DUAL_FISHEYE : 1,
+ CUBEMAP_3X2 : 2,
+ SINGLE_FISHEYE : 3,
+});
+
+const PROJECTION_MAP = {
+ 'equirectangular': Projection.EQUIRECTANGULAR,
+ 'dual_fisheye': Projection.DUAL_FISHEYE,
+ 'cubemap': Projection.CUBEMAP_3X2,
+ 'single_fisheye': Projection.SINGLE_FISHEYE,
+};
+
+const PROJECTION_GLSL = `
+// ── Equirectangular ──────────────────────────────────────────────────────────
+vec2 equirectUV(vec3 dir) {
+ float lon = atan(dir.z, dir.x);
+ float lat = asin(clamp(dir.y, -1.0, 1.0));
+ return vec2(
+ lon / (2.0 * PI) + 0.5,
+ 1.0 - (lat / PI + 0.5)
+ );
+}
+
+// ── Single fisheye ───────────────────────────────────────────────────────────
+vec2 singleFisheyeUV(vec3 dir, float fovRad) {
+ float theta = acos(clamp(dir.z, -1.0, 1.0));
+ float r = clamp(theta / fovRad, 0.0, 1.0);
+ float phi = atan(dir.y, dir.x);
+ return vec2(
+ cos(phi) * r * 0.5 + 0.5,
+ 1.0 - (sin(phi) * r * 0.5 + 0.5)
+ );
+}
+
+// ── Dual fisheye ─────────────────────────────────────────────────────────────
+vec2 dualFisheyeUV(vec3 dir, float fovRad) {
+ bool front = dir.z < 0.0;
+ float theta = acos(clamp(front ? -dir.z : dir.z, 0.0, 1.0));
+ float r = clamp(theta / fovRad, 0.0, 1.0);
+ float phi = atan(dir.y, front ? dir.x : -dir.x);
+ vec2 circle = vec2(cos(phi) * r * 0.25, sin(phi) * r * 0.5);
+ float xOffset = front ? 0.25 : 0.75;
+ return vec2(circle.x + xOffset, 1.0 - (circle.y + 0.5));
+}
+
+// ── Cubemap 3x2 ──────────────────────────────────────────────────────────────
+// FFmpeg c3x2 default face order: rludfb
+// Row 0: RIGHT(0) LEFT(1) DOWN(2)
+// Row 1: UP(3) BACK(4) FRONT(5)
+void cubeSelect(vec3 dir, out int face, out float fu, out float fv) {
+ vec3 a = abs(dir);
+
+ if (a.z >= a.x && a.z >= a.y) {
+ if (dir.z > 0.0) {
+ face = 5; // FRONT (+Z)
+ fu = -dir.x / a.z;
+ fv = -dir.y / a.z;
+ } else {
+ face = 4; // BACK (-Z)
+ fu = dir.x / a.z;
+ fv = -dir.y / a.z;
+ }
+ } else if (a.x >= a.y) {
+ if (dir.x > 0.0) {
+ face = 0; // RIGHT (+X)
+ fu = dir.z / a.x;
+ fv = -dir.y / a.x;
+ } else {
+ face = 1; // LEFT (-X)
+ fu = -dir.z / a.x;
+ fv = -dir.y / a.x;
+ }
+ } else {
+ if (dir.y < 0.0) {
+ face = 3; // UP
+ fu = dir.x / a.y;
+ fv = dir.z / a.y;
+ } else {
+ face = 2; // DOWN
+ fu = dir.x / a.y;
+ fv = -dir.z / a.y;
+ }
+ }
+}
+
+vec2 atlasUV_3x2(int face, float fu, float fv) {
+ vec2 uv = vec2(fu, fv) * 0.5 + 0.5;
+ float col = float(face < 3 ? face : face - 3);
+ float row = float(face < 3 ? 0 : 1);
+ return vec2((col + uv.x) / 3.0, (row + uv.y) / 2.0);
+}
+
+vec2 cubemapUV(vec3 dir) {
+ int face; float fu, fv;
+ cubeSelect(dir, face, fu, fv);
+ return atlasUV_3x2(face, fu, fv);
+}
+`;
+
+function buildFragSrc420() {
+ return `
+precision highp float;
+const float PI = 3.14159265358979323846;
+
+varying highp vec3 vDir;
+
+uniform sampler2D ySampler;
+uniform sampler2D uSampler;
+uniform sampler2D vSampler;
+uniform mat4 YUV2RGB;
+
+uniform int uProjection;
+uniform float uFisheyeFovRad;
+
+${PROJECTION_GLSL}
+
+void main(void) {
+ vec3 dir = normalize(vDir);
+ vec2 uv;
+
+ if (uProjection == 0) uv = equirectUV(dir);
+ else if (uProjection == 1) uv = dualFisheyeUV(dir, uFisheyeFovRad);
+ else if (uProjection == 2) uv = cubemapUV(dir);
+ else if (uProjection == 3) uv = singleFisheyeUV(dir, uFisheyeFovRad);
+ else uv = equirectUV(dir);
+
+ float y = texture2D(ySampler, uv).r;
+ float u = texture2D(uSampler, uv).r;
+ float v = texture2D(vSampler, uv).r;
+ gl_FragColor = vec4(y, u, v, 1.0) * YUV2RGB;
+}`;
+}
+
+function buildFragSrc422() {
+ return `
+precision highp float;
+const float PI = 3.14159265358979323846;
+
+varying highp vec3 vDir;
+
+uniform sampler2D sampler;
+uniform highp vec2 resolution;
+uniform mat4 YUV2RGB;
+
+uniform int uProjection;
+uniform float uFisheyeFovRad;
+
+${PROJECTION_GLSL}
+
+void main(void) {
+ vec3 dir = normalize(vDir);
+ vec2 uv;
+
+ if (uProjection == 0) uv = equirectUV(dir);
+ else if (uProjection == 1) uv = dualFisheyeUV(dir, uFisheyeFovRad);
+ else if (uProjection == 2) uv = cubemapUV(dir);
+ else if (uProjection == 3) uv = singleFisheyeUV(dir, uFisheyeFovRad);
+ else uv = equirectUV(dir);
+
+ float texPixX = 1.0 / resolution.x;
+ float logPixX = 2.0 / resolution.x;
+ float logHalfPixX = 4.0 / resolution.x;
+ float steps = floor(uv.x / logPixX);
+ float uvSteps = floor(uv.x / logHalfPixX);
+ float y = texture2D(sampler, vec2(logPixX * steps + texPixX, uv.y)).r;
+ float u = texture2D(sampler, vec2(logHalfPixX * uvSteps, uv.y)).r;
+ float v = texture2D(sampler, vec2(logHalfPixX * uvSteps + 2.0*texPixX, uv.y)).r;
+ gl_FragColor = vec4(y, u, v, 1.0) * YUV2RGB;
+}`;
+}
+
+const VERT_SRC = `
+attribute vec3 vertexPos;
+uniform mat4 MVP;
+varying highp vec3 vDir;
+
+void main() {
+ gl_Position = MVP * vec4(vertexPos, 1.0);
+ vDir = vertexPos;
+}`;
+
+class YUV360Canvas extends YUVCanvas {
+
+ /**
+ * @param {object} parOptions
+ *
+ * Viewing camera:
+ * @param {number} [parOptions.initialYaw=0] Initial yaw in radians
+ * @param {number} [parOptions.initialPitch=0] Initial pitch in radians
+ * @param {number} [parOptions.fov=75] Viewer field of view in degrees
+ *
+ * Source projection:
+ * @param {string} [parOptions.projection='equirectangular']
+ * One of: 'equirectangular', 'dual_fisheye', 'cubemap', 'single_fisheye'
+ *
+ * Fisheye lens parameters (dual_fisheye and single_fisheye only):
+ * @param {number} [parOptions.fisheyeFovDeg=204]
+ * Full field of view of each fisheye lens in degrees.
+ * 204° is the empirical value for the Insta360 X-series.
+ * This is the lens FOV, not the viewer FOV.
+ */
+ constructor(parOptions) {
+ parOptions = parOptions || {};
+ super(parOptions);
+
+ this.yaw = parOptions.initialYaw != null ? parOptions.initialYaw : 0;
+ this.pitch = parOptions.initialPitch != null ? parOptions.initialPitch : 0;
+ this.fovDeg = parOptions.fov != null ? parOptions.fov : 75;
+
+ this.projection = PROJECTION_MAP[parOptions.projection] ?? Projection.EQUIRECTANGULAR;
+
+ const fullFovDeg = parOptions.fisheyeFovDeg != null ? parOptions.fisheyeFovDeg : 204;
+ this.fisheyeFovRad = (fullFovDeg / 2) * (Math.PI / 180);
+
+ this._initCameraControls();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Public API
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Switch the source projection at runtime.
+ * @param {string|number} projection String key or Projection.* constant
+ */
+ setProjection(projection) {
+ this.projection = typeof projection === 'string'
+ ? (PROJECTION_MAP[projection] ?? Projection.EQUIRECTANGULAR)
+ : projection;
+ this._uploadProjectionUniforms();
+ }
+
+ /**
+ * Update the fisheye lens FOV (full angle in degrees).
+ * @param {number} fullFovDeg Full lens FOV in degrees (e.g. 204 for Insta360)
+ */
+ setFisheyeFov(fullFovDeg) {
+ this.fisheyeFovRad = (fullFovDeg / 2) * (Math.PI / 180);
+ this._uploadProjectionUniforms();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Core init overrides
+ // ─────────────────────────────────────────────────────────────────────────
+
+ init() {
+ this.initContextGL();
+
+ if (this.contextGL) {
+ this.initProgram();
+ this.initBuffers();
+ this.initTextures();
+ }
+
+ if (this.type === 'yuv420') {
+ this.drawNextOutputPictureGL = (par) => this._drawYUV420(par);
+ } else if (this.type === 'yuv422') {
+ this.drawNextOutputPictureGL = (par) => this._drawYUV422(par);
+ }
+ }
+
+ initProgram() {
+ const gl = this.contextGL;
+
+ const fragSrc = this.type === 'yuv420' ? buildFragSrc420() : buildFragSrc422();
+
+ let YUV2RGB;
+ if (this.conversionType === 'rec709') {
+ YUV2RGB = [
+ 1.16438, 0.00000, 1.79274, -0.97295,
+ 1.16438, -0.21325, -0.53291, 0.30148,
+ 1.16438, 2.11240, 0.00000, -1.13340,
+ 0, 0, 0, 1,
+ ];
+ } else {
+ YUV2RGB = [
+ 1.16438, 0.00000, 1.59603, -0.87079,
+ 1.16438, -0.39176, -0.81297, 0.52959,
+ 1.16438, 2.01723, 0.00000, -1.08139,
+ 0, 0, 0, 1,
+ ];
+ }
+
+ const vert = this._compileShader(gl.VERTEX_SHADER, VERT_SRC);
+ const frag = this._compileShader(gl.FRAGMENT_SHADER, fragSrc);
+
+ const program = gl.createProgram();
+ gl.attachShader(program, vert);
+ gl.attachShader(program, frag);
+ gl.linkProgram(program);
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+ console.error('YUV360Canvas: program link error —',
+ gl.getProgramInfoLog(program));
+ }
+
+ gl.useProgram(program);
+
+ gl.uniformMatrix4fv(
+ gl.getUniformLocation(program, 'YUV2RGB'), false, YUV2RGB);
+
+ this.mvpUniform = gl.getUniformLocation(program, 'MVP');
+ this.projectionUniform = gl.getUniformLocation(program, 'uProjection');
+ this.fisheyeFovUniform = gl.getUniformLocation(program, 'uFisheyeFovRad');
+ this.shaderProgram = program;
+
+ this._uploadProjectionUniforms();
+ }
+
+ initBuffers() {
+ const gl = this.contextGL;
+ const program = this.shaderProgram;
+
+ const LAT_BANDS = 32;
+ const LONG_BANDS = 64;
+
+ const { positions, texCoords, indices } = this._buildSphere(LAT_BANDS, LONG_BANDS);
+ this.sphereIndexCount = indices.length;
+
+ const posBuf = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
+ const posLoc = gl.getAttribLocation(program, 'vertexPos');
+ gl.enableVertexAttribArray(posLoc);
+ gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, 0, 0);
+
+ const uvBuf = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, uvBuf);
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(texCoords), gl.STATIC_DRAW);
+
+ const idxBuf = gl.createBuffer();
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf);
+ gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);
+ this.sphereIndexBuffer = idxBuf;
+ }
+
+ initTexture() {
+ const gl = this.contextGL;
+ const textureRef = gl.createTexture();
+ gl.bindTexture(gl.TEXTURE_2D, textureRef);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.bindTexture(gl.TEXTURE_2D, null);
+ return textureRef;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Per-frame draw
+ // ─────────────────────────────────────────────────────────────────────────
+
+ _drawYUV420(par) {
+ const gl = this.contextGL;
+ const { yData, uData, vData } = par;
+ const width = this.width;
+ const height = this.height;
+
+ const yDataPerRow = par.yDataPerRow || width;
+ const yRowCnt = par.yRowCnt || height;
+ const uDataPerRow = par.uDataPerRow || (width / 2);
+ const uRowCnt = par.uRowCnt || (height / 2);
+ const vDataPerRow = par.vDataPerRow || uDataPerRow;
+ const vRowCnt = par.vRowCnt || uRowCnt;
+
+ gl.viewport(0, 0, this.canvasElement.width, this.canvasElement.height);
+ gl.clearColor(0, 0, 0, 1);
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
+ gl.enable(gl.DEPTH_TEST);
+ gl.disable(gl.CULL_FACE);
+
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, this.yTextureRef);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE,
+ yDataPerRow, yRowCnt, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, yData);
+
+ gl.activeTexture(gl.TEXTURE1);
+ gl.bindTexture(gl.TEXTURE_2D, this.uTextureRef);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE,
+ uDataPerRow, uRowCnt, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, uData);
+
+ gl.activeTexture(gl.TEXTURE2);
+ gl.bindTexture(gl.TEXTURE_2D, this.vTextureRef);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE,
+ vDataPerRow, vRowCnt, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, vData);
+
+ gl.uniformMatrix4fv(this.mvpUniform, false, this._computeMVP());
+
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.sphereIndexBuffer);
+ gl.drawElements(gl.TRIANGLES, this.sphereIndexCount, gl.UNSIGNED_SHORT, 0);
+ }
+
+ _drawYUV422(par) {
+ const gl = this.contextGL;
+ const { data } = par;
+ const width = this.width;
+ const height = this.height;
+ const dataPerRow = par.dataPerRow || (width * 2);
+ const rowCnt = par.rowCnt || height;
+
+ gl.viewport(0, 0, this.canvasElement.width, this.canvasElement.height);
+ gl.clearColor(0, 0, 0, 1);
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
+ gl.enable(gl.DEPTH_TEST);
+ gl.disable(gl.CULL_FACE);
+
+ gl.uniform2f(
+ gl.getUniformLocation(this.shaderProgram, 'resolution'),
+ dataPerRow, height);
+
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, this.textureRef);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE,
+ dataPerRow, rowCnt, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, data);
+
+ gl.uniformMatrix4fv(this.mvpUniform, false, this._computeMVP());
+
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.sphereIndexBuffer);
+ gl.drawElements(gl.TRIANGLES, this.sphereIndexCount, gl.UNSIGNED_SHORT, 0);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Projection uniforms
+ // ─────────────────────────────────────────────────────────────────────────
+
+ _uploadProjectionUniforms() {
+ const gl = this.contextGL;
+ if (!gl || !this.shaderProgram) return;
+ gl.useProgram(this.shaderProgram);
+ gl.uniform1i(this.projectionUniform, this.projection);
+ gl.uniform1f(this.fisheyeFovUniform, this.fisheyeFovRad);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Sphere mesh
+ // ─────────────────────────────────────────────────────────────────────────
+
+ _buildSphere(latBands, longBands) {
+ const positions = [];
+ const texCoords = [];
+ const indices = [];
+
+ for (let lat = 0; lat <= latBands; lat++) {
+ const theta = (lat / latBands) * Math.PI;
+ const sinTheta = Math.sin(theta);
+ const cosTheta = Math.cos(theta);
+
+ for (let lon = 0; lon <= longBands; lon++) {
+ const phi = (lon / longBands) * 2 * Math.PI;
+ const sinPhi = Math.sin(phi);
+ const cosPhi = Math.cos(phi);
+
+ positions.push(cosPhi * sinTheta, cosTheta, sinPhi * sinTheta);
+ texCoords.push(1 - lon / longBands, lat / latBands);
+ }
+ }
+
+ for (let lat = 0; lat < latBands; lat++) {
+ for (let lon = 0; lon < longBands; lon++) {
+ const a = lat * (longBands + 1) + lon;
+ const b = a + 1;
+ const c = (lat + 1) * (longBands + 1) + lon;
+ const d = c + 1;
+ indices.push(a, c, b);
+ indices.push(b, c, d);
+ }
+ }
+
+ return { positions, texCoords, indices };
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Camera & MVP
+ // ─────────────────────────────────────────────────────────────────────────
+
+ _computeMVP() {
+ const fov = ((this.fovDeg != null ? this.fovDeg : 75) * Math.PI) / 180;
+ const aspect = this.canvasElement.width / (this.canvasElement.height || 1);
+ const yaw = this.yaw || 0;
+ const pitch = this.pitch || 0;
+
+ const proj = this._mat4Perspective(fov, aspect, 0.1, 100);
+ const view = this._mat4Mul(
+ this._mat4RotX(-pitch),
+ this._mat4RotY(-yaw)
+ );
+ return this._mat4Mul(proj, view);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Camera controls
+ // ─────────────────────────────────────────────────────────────────────────
+
+ _initCameraControls() {
+ const canvas = this.canvasElement;
+ const DRAG_SENS = 0.003;
+
+ let dragging = false;
+ let lastX = 0;
+ let lastY = 0;
+
+ const onDragStart = (x, y) => { dragging = true; lastX = x; lastY = y; };
+ const onDragMove = (x, y) => {
+ if (!dragging) return;
+ const dx = x - lastX;
+ const dy = y - lastY;
+ lastX = x;
+ lastY = y;
+ this.yaw += dx * DRAG_SENS;
+ this.pitch = Math.max(-Math.PI / 2,
+ Math.min(Math.PI / 2, this.pitch + dy * DRAG_SENS));
+ };
+ const onDragEnd = () => { dragging = false; };
+
+ canvas.addEventListener('mousedown', (e) => onDragStart(e.clientX, e.clientY));
+ window.addEventListener('mousemove', (e) => onDragMove(e.clientX, e.clientY));
+ window.addEventListener('mouseup', onDragEnd);
+
+ canvas.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ this.fovDeg = Math.max(30, Math.min(120, this.fovDeg + e.deltaY * 0.05));
+ }, { passive: false });
+
+ canvas.addEventListener('touchstart', (e) => {
+ if (e.touches.length === 1) { e.preventDefault(); onDragStart(e.touches[0].clientX, e.touches[0].clientY); }
+ }, { passive: false });
+ window.addEventListener('touchmove', (e) => {
+ if (e.touches.length === 1) { e.preventDefault(); onDragMove(e.touches[0].clientX, e.touches[0].clientY); }
+ }, { passive: false });
+ window.addEventListener('touchend', onDragEnd);
+
+ let lastPinchDist = null;
+ canvas.addEventListener('touchstart', (e) => {
+ if (e.touches.length === 2) lastPinchDist = this._pinchDist(e.touches);
+ }, { passive: true });
+ canvas.addEventListener('touchmove', (e) => {
+ if (e.touches.length === 2 && lastPinchDist !== null) {
+ const dist = this._pinchDist(e.touches);
+ this.fovDeg = Math.max(30, Math.min(120, this.fovDeg - (dist - lastPinchDist) * 0.1));
+ lastPinchDist = dist;
+ }
+ }, { passive: true });
+ canvas.addEventListener('touchend', () => { lastPinchDist = null; });
+ canvas.addEventListener('touchcancel', () => { lastPinchDist = null; });
+ }
+
+ _pinchDist(touches) {
+ const dx = touches[0].clientX - touches[1].clientX;
+ const dy = touches[0].clientY - touches[1].clientY;
+ return Math.sqrt(dx * dx + dy * dy);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Matrix helpers
+ // ─────────────────────────────────────────────────────────────────────────
+
+ _mat4Perspective(fov, aspect, near, far) {
+ const f = 1 / Math.tan(fov / 2);
+ const nf = 1 / (near - far);
+ return new Float32Array([
+ f / aspect, 0, 0, 0,
+ 0, f, 0, 0,
+ 0, 0, (far + near) * nf, -1,
+ 0, 0, 2*far*near*nf, 0,
+ ]);
+ }
+
+ _mat4RotY(a) {
+ const c = Math.cos(a), s = Math.sin(a);
+ return new Float32Array([ c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1 ]);
+ }
+
+ _mat4RotX(a) {
+ const c = Math.cos(a), s = Math.sin(a);
+ return new Float32Array([ 1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1 ]);
+ }
+
+ _mat4Mul(a, b) {
+ const out = new Float32Array(16);
+ for (let col = 0; col < 4; col++)
+ for (let row = 0; row < 4; row++) {
+ let sum = 0;
+ for (let k = 0; k < 4; k++) sum += a[k*4+row] * b[col*4+k];
+ out[col*4+row] = sum;
+ }
+ return out;
+ }
+
+ _compileShader(type, src) {
+ const gl = this.contextGL;
+ const shader = gl.createShader(type);
+ gl.shaderSource(shader, src);
+ gl.compileShader(shader);
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ const label = type === gl.VERTEX_SHADER ? 'Vertex' : 'Fragment';
+ console.error(`YUV360Canvas: ${label} shader compile error —`,
+ gl.getShaderInfoLog(shader));
+ }
+ return shader;
+ }
+}
+
+export default YUV360Canvas;
\ No newline at end of file
diff --git a/src/lib/VisualizationHelpers.ts b/src/lib/VisualizationHelpers.ts
new file mode 100644
index 00000000..e69de29b
diff --git a/src/modules/visualization/visualizations/video/Builder.ts b/src/modules/visualization/visualizations/video/Builder.ts
index c95e245b..a29750f7 100644
--- a/src/modules/visualization/visualizations/video/Builder.ts
+++ b/src/modules/visualization/visualizations/video/Builder.ts
@@ -83,6 +83,7 @@ export function CreateVideoVizProps(
useWebCodecApi: true,
showTime: visOptions?.time,
showStats: visOptions?.stats,
+ props360: visOptions?.props360,
};
for (const [dsId, entry] of Object.entries(datastreams)) {
diff --git a/src/modules/visualization/wizard/customizations/VideoOptions.vue b/src/modules/visualization/wizard/customizations/VideoOptions.vue
index a0ab9fe0..fe18756b 100644
--- a/src/modules/visualization/wizard/customizations/VideoOptions.vue
+++ b/src/modules/visualization/wizard/customizations/VideoOptions.vue
@@ -1,11 +1,34 @@
@@ -45,4 +108,25 @@ onMounted(() => {
label="Show Video Time"
density="compact"
/>
+
+
+