From 8dda86d0cbe614413101cec6ed5cc24953e24fac Mon Sep 17 00:00:00 2001 From: chiqden <50296321+chiqden@users.noreply.github.com> Date: Sat, 11 May 2019 16:03:44 +0900 Subject: [PATCH] v1.0.0 --- README.md | 26 +- index.html | 31 + scripts/180-stereo-photo-viewer.js | 259 ++++ ...s-v2.3.0-added-support-for-extended-xmp.js | 1110 +++++++++++++++++ scripts/offline-support.js | 10 + service-worker.js | 46 + 6 files changed, 1481 insertions(+), 1 deletion(-) create mode 100644 index.html create mode 100644 scripts/180-stereo-photo-viewer.js create mode 100644 scripts/exif-js-v2.3.0-added-support-for-extended-xmp.js create mode 100644 scripts/offline-support.js create mode 100644 service-worker.js diff --git a/README.md b/README.md index 2c6cc54..fd1b507 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,26 @@ -# 180-stereo-photo-viewer +# 180 Stereo Photo Viewer A VR180 photo viewer that works on a web browser. + +### Supported photo formats +- VR180 photos (vr.jpg) +- 180 stereo side by side photos + +### Features +- Zenith correction (support Cardboard Camera VR Photo Format) +- Offline support (experimental) + +### Notes +- You need “Motion & Orientation Access” permission to use with Safari on iOS 12.2. + `Settings` -> `Safari` -> `PRIVACY & SECURITY` -> `Motion & Orientation Access` + +### Usage +- Specify a photo in HTML file + Set the path of the photo to `index.html` ``. +- Select a photo in file picker + Double-click(tap) on the browser to select a photo. +- To disable file picker + Remove `file-picker` component from `index.html` `` +- To disable offline support + 1. Comment out `index.html` ``. + 2. Unregister the Service Worker. + 3. Delete the cache. diff --git a/index.html b/index.html new file mode 100644 index 0000000..8ca5237 --- /dev/null +++ b/index.html @@ -0,0 +1,31 @@ + + + + 180 Stereo Photo Viewer + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scripts/180-stereo-photo-viewer.js b/scripts/180-stereo-photo-viewer.js new file mode 100644 index 0000000..8f54e38 --- /dev/null +++ b/scripts/180-stereo-photo-viewer.js @@ -0,0 +1,259 @@ +const STEREO_IMAGE_ID = 'stereoImage'; +const LEFT_EYE_IMAGE_ID = 'leftEyeImage'; +const RIGHT_EYE_IMAGE_ID = 'rightEyeImage'; +const LEFT_EYE_HEMISPHERE_ID = 'leftEyeHemisphere'; +const RIGHT_EYE_HEMISPHERE_ID = 'rightEyeHemisphere'; + +AFRAME.registerComponent('180-stereo-photo-viewer', { + init: function () { + this.el.sceneEl.addEventListener("renderstart", function () { + this.camera.layers.enable(1); + }); + } +}); + +AFRAME.registerComponent('stereo', { + schema: { + eye: {type: 'string'} + }, + init: function () { + let layer; + switch (this.data.eye) { + case 'left': + layer = 1; + break; + case 'right': + layer = 2; + break; + default: + layer = 0; + } + let userAgent = navigator.userAgent; + if (userAgent.includes('Edge')) { + this.el.sceneEl.addEventListener('renderstart', () => { + this.el.object3DMap.mesh.layers.set(layer); + }) + } else { + this.el.object3DMap.mesh.layers.set(layer); + } + } +}); + +AFRAME.registerComponent('file-picker', { + init: function () { + var tapCount = 0; + let input = document.createElement('input'); + input.type = 'file'; + input.accept = '.jpg, .jpeg, image/jpeg'; + document.body.appendChild(input); + + let stereoImage = document.getElementById(STEREO_IMAGE_ID); + if (stereoImage.src === document.documentURI) { + setVisibilityUsageText(true); + } + + window.addEventListener('click', function () { + if (tapCount === 0) { + tapCount++; + setTimeout(function () { + tapCount = 0; + }, 350); + } else { + setVisibilityUsageText(false); + input.click(); + } + }); + + window.addEventListener('change', function () { + let timeout = 0; + if (document.getElementById(STEREO_IMAGE_ID).src !== document.documentURI) { + document.getElementById(LEFT_EYE_HEMISPHERE_ID).dispatchEvent(new Event('fadeOut')); + document.getElementById(RIGHT_EYE_HEMISPHERE_ID).dispatchEvent(new Event('fadeOut')); + timeout = 300; + } + setTimeout(() => { + setVisibilityLoadingText(true); + let input = document.querySelector('input'); + readAsDataURLFromFile(input.files[0]) + .then(dataURL => { + let stereoImage = document.getElementById(STEREO_IMAGE_ID); + delete stereoImage.exifdata; + delete stereoImage.xmpdata; + delete stereoImage.extendedxmpdata; + return loadImage(stereoImage, dataURL); + }) + .then(stereoImage => { + load180StereoImage(stereoImage); + }) + }, timeout); + }) + } +}); + +window.onload = () => { + generateBothEyeImages(); + generateBothEyeHemispheres(); + + let stereoImage = document.getElementById(STEREO_IMAGE_ID); + if (stereoImage.src === document.documentURI) { + setVisibilityLoadingText(false); + return; + } + + load180StereoImage(stereoImage); +}; + +function generateBothEyeImages() { + let assets = document.querySelector('a-assets'); + let leftEyeImage = document.createElement('img'); + leftEyeImage.id = LEFT_EYE_IMAGE_ID; + assets.appendChild(leftEyeImage); + let rightEyeImage = document.createElement('img'); + rightEyeImage.id = RIGHT_EYE_IMAGE_ID; + assets.appendChild(rightEyeImage); +} + +function generateBothEyeHemispheres() { + let scene = document.querySelector('a-scene'); + for (let i = 0; i < 2; i++) { + let hemisphere = document.createElement('a-entity'); + hemisphere.setAttribute('geometry', 'primitive: sphere; radius:100; segmentsWidth: 64; segmentsHeight:64; phi-start: 180; phi-length: 180'); + hemisphere.setAttribute('material', 'shader: flat; color: black; side: back; npot: true'); + hemisphere.setAttribute('scale', '-1 1 1'); + hemisphere.setAttribute('animation__fade-out', 'property: components.material.material.color; type: color; from: #FFF; to: #000; dur: 300; startEvents: fadeOut'); + hemisphere.setAttribute('animation__fade-in', 'property: components.material.material.color; type: color; from: #000; to: #FFF; dur: 300; startEvents: fadeIn'); + + if (i === 0) { + hemisphere.id = LEFT_EYE_HEMISPHERE_ID; + AFRAME.utils.entity.setComponentProperty(hemisphere, 'stereo', 'eye: left'); + } else { + hemisphere.id = RIGHT_EYE_HEMISPHERE_ID; + AFRAME.utils.entity.setComponentProperty(hemisphere, 'stereo', 'eye: right'); + } + + scene.appendChild(hemisphere); + } +} + +function readAsDataURLFromFile(file) { + return new Promise((resolve, reject) => { + let reader = new FileReader(); + reader.onload = event => { + resolve(event.target.result); + }; + reader.onerror = error => { + reject(new Error(error)); + }; + reader.readAsDataURL(file); + }); +} + +function getExifData(image) { + return new Promise((resolve => { + EXIF.enableXmp(); + EXIF.getData(image, () => { + resolve(image); + }); + })) +} + +function loadImage(image, url) { + return new Promise((resolve, reject) => { + image.onload = () => { + resolve(image); + }; + image.onerror = error => { + reject(new Error(error)); + }; + image.src = url; + }); +} + +function load180StereoImage(stereoImage) { + getExifData(stereoImage) + .then(stereoImage => { + let leftEyeImage = document.getElementById(LEFT_EYE_IMAGE_ID); + let rightEyeImage = document.getElementById(RIGHT_EYE_IMAGE_ID); + + if ('extendedxmpdata' in stereoImage) { + loadVR180Image(stereoImage, rightEyeImage); + } else { + loadSBS180Image(stereoImage, leftEyeImage, rightEyeImage); + } + + applyZenithCorrection(stereoImage); + }) +} + +function loadVR180Image(leftEyeImage, rightEyeImage) { + let gImageData = leftEyeImage.extendedxmpdata['x:xmpmeta']['rdf:RDF']['rdf:Description']['@attributes']['GImage:Data']; + loadImage(rightEyeImage, 'data:image/jpeg;base64,' + gImageData) + .then(rightEyeImage => updateBothEyeTextures([leftEyeImage, rightEyeImage])); +} + +function loadSBS180Image(stereoImage, leftEyeImage, rightEyeImage) { + let canvas = document.createElement('canvas'); + let context = canvas.getContext('2d'); + + canvas.width = stereoImage.width / 2; + canvas.height = stereoImage.height; + + context.drawImage(stereoImage, 0, 0, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height); + context.drawImage(stereoImage, canvas.width, 0, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height); + + Promise.all([ + loadImage(leftEyeImage, canvas.toDataURL('image/jpeg', 1)), + loadImage(rightEyeImage, canvas.toDataURL('image/jpeg', 1))]) + .then(images => updateBothEyeTextures(images)) +} + +function updateBothEyeTextures(images) { + for (let i = 0; i < 2; i++) { + let entity = document.getElementById(i === 0 ? LEFT_EYE_HEMISPHERE_ID : RIGHT_EYE_HEMISPHERE_ID); + entity.setAttribute('material', 'src: ; color: '); + entity.setAttribute('material', `src: #${images[i].id}`); + entity.dispatchEvent(new Event('fadeIn')); + } + + setVisibilityLoadingText(false); +} + +function applyZenithCorrection(image) { + let leftEyeHemisphere = document.getElementById(LEFT_EYE_HEMISPHERE_ID); + let rightEyeHemisphere = document.getElementById(RIGHT_EYE_HEMISPHERE_ID); + + if ('xmpdata' in image) { + let poseRollDegrees = getPropertyValue(['x:xmpmeta', 'rdf:RDF', 'rdf:Description', '@attributes', 'GPano:PoseRollDegrees'], image.xmpdata); + if (poseRollDegrees === null) { + poseRollDegrees = '0'; + } + let posePitchDegrees = getPropertyValue(['x:xmpmeta', 'rdf:RDF', 'rdf:Description', '@attributes', 'GPano:PosePitchDegrees'], image.xmpdata); + if (posePitchDegrees === null) { + posePitchDegrees = '0'; + } + leftEyeHemisphere.setAttribute('rotation', `${-posePitchDegrees} 0 ${-poseRollDegrees}`); + rightEyeHemisphere.setAttribute('rotation', `${-posePitchDegrees} 0 ${-poseRollDegrees}`); + } else { + leftEyeHemisphere.setAttribute('rotation', '0 0 0'); + rightEyeHemisphere.setAttribute('rotation', '0 0 0'); + } +} + +function setVisibilityUsageText(visible) { + let usageText = document.getElementById('usageText'); + if (usageText) { + usageText.setAttribute('visible', visible); + } +} + +function setVisibilityLoadingText(visible) { + let loadingText = document.getElementById('loadingText'); + let position = document.querySelector('a-camera').getAttribute('position'); + loadingText.setAttribute('position', `0 ${position.y} ${position.z - 1.0}`); + if (loadingText) { + loadingText.setAttribute('visible', visible); + } +} + +const getPropertyValue = (p, o) => + p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o); diff --git a/scripts/exif-js-v2.3.0-added-support-for-extended-xmp.js b/scripts/exif-js-v2.3.0-added-support-for-extended-xmp.js new file mode 100644 index 0000000..a01e3da --- /dev/null +++ b/scripts/exif-js-v2.3.0-added-support-for-extended-xmp.js @@ -0,0 +1,1110 @@ +(function() { + + var debug = false; + + var root = this; + + var EXIF = function(obj) { + if (obj instanceof EXIF) return obj; + if (!(this instanceof EXIF)) return new EXIF(obj); + this.EXIFwrapped = obj; + }; + + if (typeof exports !== 'undefined') { + if (typeof module !== 'undefined' && module.exports) { + exports = module.exports = EXIF; + } + exports.EXIF = EXIF; + } else { + root.EXIF = EXIF; + } + + var ExifTags = EXIF.Tags = { + + // version tags + 0x9000 : "ExifVersion", // EXIF version + 0xA000 : "FlashpixVersion", // Flashpix format version + + // colorspace tags + 0xA001 : "ColorSpace", // Color space information tag + + // image configuration + 0xA002 : "PixelXDimension", // Valid width of meaningful image + 0xA003 : "PixelYDimension", // Valid height of meaningful image + 0x9101 : "ComponentsConfiguration", // Information about channels + 0x9102 : "CompressedBitsPerPixel", // Compressed bits per pixel + + // user information + 0x927C : "MakerNote", // Any desired information written by the manufacturer + 0x9286 : "UserComment", // Comments by user + + // related file + 0xA004 : "RelatedSoundFile", // Name of related sound file + + // date and time + 0x9003 : "DateTimeOriginal", // Date and time when the original image was generated + 0x9004 : "DateTimeDigitized", // Date and time when the image was stored digitally + 0x9290 : "SubsecTime", // Fractions of seconds for DateTime + 0x9291 : "SubsecTimeOriginal", // Fractions of seconds for DateTimeOriginal + 0x9292 : "SubsecTimeDigitized", // Fractions of seconds for DateTimeDigitized + + // picture-taking conditions + 0x829A : "ExposureTime", // Exposure time (in seconds) + 0x829D : "FNumber", // F number + 0x8822 : "ExposureProgram", // Exposure program + 0x8824 : "SpectralSensitivity", // Spectral sensitivity + 0x8827 : "ISOSpeedRatings", // ISO speed rating + 0x8828 : "OECF", // Optoelectric conversion factor + 0x9201 : "ShutterSpeedValue", // Shutter speed + 0x9202 : "ApertureValue", // Lens aperture + 0x9203 : "BrightnessValue", // Value of brightness + 0x9204 : "ExposureBias", // Exposure bias + 0x9205 : "MaxApertureValue", // Smallest F number of lens + 0x9206 : "SubjectDistance", // Distance to subject in meters + 0x9207 : "MeteringMode", // Metering mode + 0x9208 : "LightSource", // Kind of light source + 0x9209 : "Flash", // Flash status + 0x9214 : "SubjectArea", // Location and area of main subject + 0x920A : "FocalLength", // Focal length of the lens in mm + 0xA20B : "FlashEnergy", // Strobe energy in BCPS + 0xA20C : "SpatialFrequencyResponse", // + 0xA20E : "FocalPlaneXResolution", // Number of pixels in width direction per FocalPlaneResolutionUnit + 0xA20F : "FocalPlaneYResolution", // Number of pixels in height direction per FocalPlaneResolutionUnit + 0xA210 : "FocalPlaneResolutionUnit", // Unit for measuring FocalPlaneXResolution and FocalPlaneYResolution + 0xA214 : "SubjectLocation", // Location of subject in image + 0xA215 : "ExposureIndex", // Exposure index selected on camera + 0xA217 : "SensingMethod", // Image sensor type + 0xA300 : "FileSource", // Image source (3 == DSC) + 0xA301 : "SceneType", // Scene type (1 == directly photographed) + 0xA302 : "CFAPattern", // Color filter array geometric pattern + 0xA401 : "CustomRendered", // Special processing + 0xA402 : "ExposureMode", // Exposure mode + 0xA403 : "WhiteBalance", // 1 = auto white balance, 2 = manual + 0xA404 : "DigitalZoomRation", // Digital zoom ratio + 0xA405 : "FocalLengthIn35mmFilm", // Equivalent foacl length assuming 35mm film camera (in mm) + 0xA406 : "SceneCaptureType", // Type of scene + 0xA407 : "GainControl", // Degree of overall image gain adjustment + 0xA408 : "Contrast", // Direction of contrast processing applied by camera + 0xA409 : "Saturation", // Direction of saturation processing applied by camera + 0xA40A : "Sharpness", // Direction of sharpness processing applied by camera + 0xA40B : "DeviceSettingDescription", // + 0xA40C : "SubjectDistanceRange", // Distance to subject + + // other tags + 0xA005 : "InteroperabilityIFDPointer", + 0xA420 : "ImageUniqueID" // Identifier assigned uniquely to each image + }; + + var TiffTags = EXIF.TiffTags = { + 0x0100 : "ImageWidth", + 0x0101 : "ImageHeight", + 0x8769 : "ExifIFDPointer", + 0x8825 : "GPSInfoIFDPointer", + 0xA005 : "InteroperabilityIFDPointer", + 0x0102 : "BitsPerSample", + 0x0103 : "Compression", + 0x0106 : "PhotometricInterpretation", + 0x0112 : "Orientation", + 0x0115 : "SamplesPerPixel", + 0x011C : "PlanarConfiguration", + 0x0212 : "YCbCrSubSampling", + 0x0213 : "YCbCrPositioning", + 0x011A : "XResolution", + 0x011B : "YResolution", + 0x0128 : "ResolutionUnit", + 0x0111 : "StripOffsets", + 0x0116 : "RowsPerStrip", + 0x0117 : "StripByteCounts", + 0x0201 : "JPEGInterchangeFormat", + 0x0202 : "JPEGInterchangeFormatLength", + 0x012D : "TransferFunction", + 0x013E : "WhitePoint", + 0x013F : "PrimaryChromaticities", + 0x0211 : "YCbCrCoefficients", + 0x0214 : "ReferenceBlackWhite", + 0x0132 : "DateTime", + 0x010E : "ImageDescription", + 0x010F : "Make", + 0x0110 : "Model", + 0x0131 : "Software", + 0x013B : "Artist", + 0x8298 : "Copyright" + }; + + var GPSTags = EXIF.GPSTags = { + 0x0000 : "GPSVersionID", + 0x0001 : "GPSLatitudeRef", + 0x0002 : "GPSLatitude", + 0x0003 : "GPSLongitudeRef", + 0x0004 : "GPSLongitude", + 0x0005 : "GPSAltitudeRef", + 0x0006 : "GPSAltitude", + 0x0007 : "GPSTimeStamp", + 0x0008 : "GPSSatellites", + 0x0009 : "GPSStatus", + 0x000A : "GPSMeasureMode", + 0x000B : "GPSDOP", + 0x000C : "GPSSpeedRef", + 0x000D : "GPSSpeed", + 0x000E : "GPSTrackRef", + 0x000F : "GPSTrack", + 0x0010 : "GPSImgDirectionRef", + 0x0011 : "GPSImgDirection", + 0x0012 : "GPSMapDatum", + 0x0013 : "GPSDestLatitudeRef", + 0x0014 : "GPSDestLatitude", + 0x0015 : "GPSDestLongitudeRef", + 0x0016 : "GPSDestLongitude", + 0x0017 : "GPSDestBearingRef", + 0x0018 : "GPSDestBearing", + 0x0019 : "GPSDestDistanceRef", + 0x001A : "GPSDestDistance", + 0x001B : "GPSProcessingMethod", + 0x001C : "GPSAreaInformation", + 0x001D : "GPSDateStamp", + 0x001E : "GPSDifferential" + }; + + // EXIF 2.3 Spec + var IFD1Tags = EXIF.IFD1Tags = { + 0x0100: "ImageWidth", + 0x0101: "ImageHeight", + 0x0102: "BitsPerSample", + 0x0103: "Compression", + 0x0106: "PhotometricInterpretation", + 0x0111: "StripOffsets", + 0x0112: "Orientation", + 0x0115: "SamplesPerPixel", + 0x0116: "RowsPerStrip", + 0x0117: "StripByteCounts", + 0x011A: "XResolution", + 0x011B: "YResolution", + 0x011C: "PlanarConfiguration", + 0x0128: "ResolutionUnit", + 0x0201: "JpegIFOffset", // When image format is JPEG, this value show offset to JPEG data stored.(aka "ThumbnailOffset" or "JPEGInterchangeFormat") + 0x0202: "JpegIFByteCount", // When image format is JPEG, this value shows data size of JPEG image (aka "ThumbnailLength" or "JPEGInterchangeFormatLength") + 0x0211: "YCbCrCoefficients", + 0x0212: "YCbCrSubSampling", + 0x0213: "YCbCrPositioning", + 0x0214: "ReferenceBlackWhite" + }; + + var StringValues = EXIF.StringValues = { + ExposureProgram : { + 0 : "Not defined", + 1 : "Manual", + 2 : "Normal program", + 3 : "Aperture priority", + 4 : "Shutter priority", + 5 : "Creative program", + 6 : "Action program", + 7 : "Portrait mode", + 8 : "Landscape mode" + }, + MeteringMode : { + 0 : "Unknown", + 1 : "Average", + 2 : "CenterWeightedAverage", + 3 : "Spot", + 4 : "MultiSpot", + 5 : "Pattern", + 6 : "Partial", + 255 : "Other" + }, + LightSource : { + 0 : "Unknown", + 1 : "Daylight", + 2 : "Fluorescent", + 3 : "Tungsten (incandescent light)", + 4 : "Flash", + 9 : "Fine weather", + 10 : "Cloudy weather", + 11 : "Shade", + 12 : "Daylight fluorescent (D 5700 - 7100K)", + 13 : "Day white fluorescent (N 4600 - 5400K)", + 14 : "Cool white fluorescent (W 3900 - 4500K)", + 15 : "White fluorescent (WW 3200 - 3700K)", + 17 : "Standard light A", + 18 : "Standard light B", + 19 : "Standard light C", + 20 : "D55", + 21 : "D65", + 22 : "D75", + 23 : "D50", + 24 : "ISO studio tungsten", + 255 : "Other" + }, + Flash : { + 0x0000 : "Flash did not fire", + 0x0001 : "Flash fired", + 0x0005 : "Strobe return light not detected", + 0x0007 : "Strobe return light detected", + 0x0009 : "Flash fired, compulsory flash mode", + 0x000D : "Flash fired, compulsory flash mode, return light not detected", + 0x000F : "Flash fired, compulsory flash mode, return light detected", + 0x0010 : "Flash did not fire, compulsory flash mode", + 0x0018 : "Flash did not fire, auto mode", + 0x0019 : "Flash fired, auto mode", + 0x001D : "Flash fired, auto mode, return light not detected", + 0x001F : "Flash fired, auto mode, return light detected", + 0x0020 : "No flash function", + 0x0041 : "Flash fired, red-eye reduction mode", + 0x0045 : "Flash fired, red-eye reduction mode, return light not detected", + 0x0047 : "Flash fired, red-eye reduction mode, return light detected", + 0x0049 : "Flash fired, compulsory flash mode, red-eye reduction mode", + 0x004D : "Flash fired, compulsory flash mode, red-eye reduction mode, return light not detected", + 0x004F : "Flash fired, compulsory flash mode, red-eye reduction mode, return light detected", + 0x0059 : "Flash fired, auto mode, red-eye reduction mode", + 0x005D : "Flash fired, auto mode, return light not detected, red-eye reduction mode", + 0x005F : "Flash fired, auto mode, return light detected, red-eye reduction mode" + }, + SensingMethod : { + 1 : "Not defined", + 2 : "One-chip color area sensor", + 3 : "Two-chip color area sensor", + 4 : "Three-chip color area sensor", + 5 : "Color sequential area sensor", + 7 : "Trilinear sensor", + 8 : "Color sequential linear sensor" + }, + SceneCaptureType : { + 0 : "Standard", + 1 : "Landscape", + 2 : "Portrait", + 3 : "Night scene" + }, + SceneType : { + 1 : "Directly photographed" + }, + CustomRendered : { + 0 : "Normal process", + 1 : "Custom process" + }, + WhiteBalance : { + 0 : "Auto white balance", + 1 : "Manual white balance" + }, + GainControl : { + 0 : "None", + 1 : "Low gain up", + 2 : "High gain up", + 3 : "Low gain down", + 4 : "High gain down" + }, + Contrast : { + 0 : "Normal", + 1 : "Soft", + 2 : "Hard" + }, + Saturation : { + 0 : "Normal", + 1 : "Low saturation", + 2 : "High saturation" + }, + Sharpness : { + 0 : "Normal", + 1 : "Soft", + 2 : "Hard" + }, + SubjectDistanceRange : { + 0 : "Unknown", + 1 : "Macro", + 2 : "Close view", + 3 : "Distant view" + }, + FileSource : { + 3 : "DSC" + }, + + Components : { + 0 : "", + 1 : "Y", + 2 : "Cb", + 3 : "Cr", + 4 : "R", + 5 : "G", + 6 : "B" + } + }; + + function addEvent(element, event, handler) { + if (element.addEventListener) { + element.addEventListener(event, handler, false); + } else if (element.attachEvent) { + element.attachEvent("on" + event, handler); + } + } + + function imageHasData(img) { + return !!(img.exifdata); + } + + + function base64ToArrayBuffer(base64, contentType) { + contentType = contentType || base64.match(/^data\:([^\;]+)\;base64,/mi)[1] || ''; // e.g. 'data:image/jpeg;base64,...' => 'image/jpeg' + base64 = base64.replace(/^data\:([^\;]+)\;base64,/gmi, ''); + var binary = atob(base64); + var len = binary.length; + var buffer = new ArrayBuffer(len); + var view = new Uint8Array(buffer); + for (var i = 0; i < len; i++) { + view[i] = binary.charCodeAt(i); + } + return buffer; + } + + function objectURLToBlob(url, callback) { + var http = new XMLHttpRequest(); + http.open("GET", url, true); + http.responseType = "blob"; + http.onload = function(e) { + if (this.status == 200 || this.status === 0) { + callback(this.response); + } + }; + http.send(); + } + + function getImageData(img, callback) { + function handleBinaryFile(binFile) { + var data = findEXIFinJPEG(binFile); + img.exifdata = data || {}; + var iptcdata = findIPTCinJPEG(binFile); + img.iptcdata = iptcdata || {}; + if (EXIF.isXmpEnabled) { + var xmpdata= findXMPinJPEG(binFile); + img.xmpdata = xmpdata || {}; + + if (xmpdata) { + var guid = ['x:xmpmeta', 'rdf:RDF', 'rdf:Description', '@attributes', 'xmpNote:HasExtendedXMP'] + .reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, xmpdata); + if (guid) { + var extendedxmpdata = findExtendedXMPinJPEG(binFile, guid); + img.extendedxmpdata = extendedxmpdata || {}; + } + } + } + if (callback) { + callback.call(img); + } + } + + if (img.src) { + if (/^data\:/i.test(img.src)) { // Data URI + var arrayBuffer = base64ToArrayBuffer(img.src); + handleBinaryFile(arrayBuffer); + + } else if (/^blob\:/i.test(img.src)) { // Object URL + var fileReader = new FileReader(); + fileReader.onload = function(e) { + handleBinaryFile(e.target.result); + }; + objectURLToBlob(img.src, function (blob) { + fileReader.readAsArrayBuffer(blob); + }); + } else { + var http = new XMLHttpRequest(); + http.onload = function() { + if (this.status == 200 || this.status === 0) { + handleBinaryFile(http.response); + } else { + throw "Could not load image"; + } + http = null; + }; + http.open("GET", img.src, true); + http.responseType = "arraybuffer"; + http.send(null); + } + } else if (self.FileReader && (img instanceof self.Blob || img instanceof self.File)) { + var fileReader = new FileReader(); + fileReader.onload = function(e) { + if (debug) console.log("Got file of length " + e.target.result.byteLength); + handleBinaryFile(e.target.result); + }; + + fileReader.readAsArrayBuffer(img); + } + } + + function findEXIFinJPEG(file) { + var dataView = new DataView(file); + + if (debug) console.log("Got file of length " + file.byteLength); + if ((dataView.getUint8(0) != 0xFF) || (dataView.getUint8(1) != 0xD8)) { + if (debug) console.log("Not a valid JPEG"); + return false; // not a valid jpeg + } + + var offset = 2, + length = file.byteLength, + marker; + + while (offset < length) { + if (dataView.getUint8(offset) != 0xFF) { + if (debug) console.log("Not a valid marker at offset " + offset + ", found: " + dataView.getUint8(offset)); + return false; // not a valid marker, something is wrong + } + + marker = dataView.getUint8(offset + 1); + if (debug) console.log(marker); + + // we could implement handling for other markers here, + // but we're only looking for 0xFFE1 for EXIF data + + if (marker == 225) { + if (debug) console.log("Found 0xFFE1 marker"); + + return readEXIFData(dataView, offset + 4, dataView.getUint16(offset + 2) - 2); + + // offset += 2 + file.getShortAt(offset+2, true); + + } else { + offset += 2 + dataView.getUint16(offset+2); + } + + } + + } + + function findIPTCinJPEG(file) { + var dataView = new DataView(file); + + if (debug) console.log("Got file of length " + file.byteLength); + if ((dataView.getUint8(0) != 0xFF) || (dataView.getUint8(1) != 0xD8)) { + if (debug) console.log("Not a valid JPEG"); + return false; // not a valid jpeg + } + + var offset = 2, + length = file.byteLength; + + + var isFieldSegmentStart = function(dataView, offset){ + return ( + dataView.getUint8(offset) === 0x38 && + dataView.getUint8(offset+1) === 0x42 && + dataView.getUint8(offset+2) === 0x49 && + dataView.getUint8(offset+3) === 0x4D && + dataView.getUint8(offset+4) === 0x04 && + dataView.getUint8(offset+5) === 0x04 + ); + }; + + while (offset < length) { + + if ( isFieldSegmentStart(dataView, offset )){ + + // Get the length of the name header (which is padded to an even number of bytes) + var nameHeaderLength = dataView.getUint8(offset+7); + if(nameHeaderLength % 2 !== 0) nameHeaderLength += 1; + // Check for pre photoshop 6 format + if(nameHeaderLength === 0) { + // Always 4 + nameHeaderLength = 4; + } + + var startOffset = offset + 8 + nameHeaderLength; + var sectionLength = dataView.getUint16(offset + 6 + nameHeaderLength); + + return readIPTCData(file, startOffset, sectionLength); + + break; + + } + + + // Not the marker, continue searching + offset++; + + } + + } + var IptcFieldMap = { + 0x78 : 'caption', + 0x6E : 'credit', + 0x19 : 'keywords', + 0x37 : 'dateCreated', + 0x50 : 'byline', + 0x55 : 'bylineTitle', + 0x7A : 'captionWriter', + 0x69 : 'headline', + 0x74 : 'copyright', + 0x0F : 'category' + }; + function readIPTCData(file, startOffset, sectionLength){ + var dataView = new DataView(file); + var data = {}; + var fieldValue, fieldName, dataSize, segmentType, segmentSize; + var segmentStartPos = startOffset; + while(segmentStartPos < startOffset+sectionLength) { + if(dataView.getUint8(segmentStartPos) === 0x1C && dataView.getUint8(segmentStartPos+1) === 0x02){ + segmentType = dataView.getUint8(segmentStartPos+2); + if(segmentType in IptcFieldMap) { + dataSize = dataView.getInt16(segmentStartPos+3); + segmentSize = dataSize + 5; + fieldName = IptcFieldMap[segmentType]; + fieldValue = getStringFromDB(dataView, segmentStartPos+5, dataSize); + // Check if we already stored a value with this name + if(data.hasOwnProperty(fieldName)) { + // Value already stored with this name, create multivalue field + if(data[fieldName] instanceof Array) { + data[fieldName].push(fieldValue); + } + else { + data[fieldName] = [data[fieldName], fieldValue]; + } + } + else { + data[fieldName] = fieldValue; + } + } + + } + segmentStartPos++; + } + return data; + } + + + + function readTags(file, tiffStart, dirStart, strings, bigEnd) { + var entries = file.getUint16(dirStart, !bigEnd), + tags = {}, + entryOffset, tag, + i; + + for (i=0;i 4 ? valueOffset : (entryOffset + 8); + vals = []; + for (n=0;n 4 ? valueOffset : (entryOffset + 8); + return getStringFromDB(file, offset, numValues-1); + + case 3: // short, 16 bit int + if (numValues == 1) { + return file.getUint16(entryOffset + 8, !bigEnd); + } else { + offset = numValues > 2 ? valueOffset : (entryOffset + 8); + vals = []; + for (n=0;n dataView.byteLength) { // this should not happen + // console.log('******** IFD1Offset is outside the bounds of the DataView ********'); + return {}; + } + // console.log('******* thumbnail IFD offset (IFD1) is: %s', IFD1OffsetPointer); + + var thumbTags = readTags(dataView, tiffStart, tiffStart + IFD1OffsetPointer, IFD1Tags, bigEnd) + + // EXIF 2.3 specification for JPEG format thumbnail + + // If the value of Compression(0x0103) Tag in IFD1 is '6', thumbnail image format is JPEG. + // Most of Exif image uses JPEG format for thumbnail. In that case, you can get offset of thumbnail + // by JpegIFOffset(0x0201) Tag in IFD1, size of thumbnail by JpegIFByteCount(0x0202) Tag. + // Data format is ordinary JPEG format, starts from 0xFFD8 and ends by 0xFFD9. It seems that + // JPEG format and 160x120pixels of size are recommended thumbnail format for Exif2.1 or later. + + if (thumbTags['Compression']) { + // console.log('Thumbnail image found!'); + + switch (thumbTags['Compression']) { + case 6: + // console.log('Thumbnail image format is JPEG'); + if (thumbTags.JpegIFOffset && thumbTags.JpegIFByteCount) { + // extract the thumbnail + var tOffset = tiffStart + thumbTags.JpegIFOffset; + var tLength = thumbTags.JpegIFByteCount; + thumbTags['blob'] = new Blob([new Uint8Array(dataView.buffer, tOffset, tLength)], { + type: 'image/jpeg' + }); + } + break; + + case 1: + console.log("Thumbnail image format is TIFF, which is not implemented."); + break; + default: + console.log("Unknown thumbnail image format '%s'", thumbTags['Compression']); + } + } + else if (thumbTags['PhotometricInterpretation'] == 2) { + console.log("Thumbnail image format is RGB, which is not implemented."); + } + return thumbTags; + } + + function getStringFromDB(buffer, start, length) { + var outstr = ""; + for (var n = start; n < start+length; n++) { + outstr += String.fromCharCode(buffer.getUint8(n)); + } + return outstr; + } + + function readEXIFData(file, start) { + if (getStringFromDB(file, start, 4) != "Exif") { + if (debug) console.log("Not valid EXIF data! " + getStringFromDB(file, start, 4)); + return false; + } + + var bigEnd, + tags, tag, + exifData, gpsData, + tiffOffset = start + 6; + + // test for TIFF validity and endianness + if (file.getUint16(tiffOffset) == 0x4949) { + bigEnd = false; + } else if (file.getUint16(tiffOffset) == 0x4D4D) { + bigEnd = true; + } else { + if (debug) console.log("Not valid TIFF data! (no 0x4949 or 0x4D4D)"); + return false; + } + + if (file.getUint16(tiffOffset+2, !bigEnd) != 0x002A) { + if (debug) console.log("Not valid TIFF data! (no 0x002A)"); + return false; + } + + var firstIFDOffset = file.getUint32(tiffOffset+4, !bigEnd); + + if (firstIFDOffset < 0x00000008) { + if (debug) console.log("Not valid TIFF data! (First offset less than 8)", file.getUint32(tiffOffset+4, !bigEnd)); + return false; + } + + tags = readTags(file, tiffOffset, tiffOffset + firstIFDOffset, TiffTags, bigEnd); + + if (tags.ExifIFDPointer) { + exifData = readTags(file, tiffOffset, tiffOffset + tags.ExifIFDPointer, ExifTags, bigEnd); + for (tag in exifData) { + switch (tag) { + case "LightSource" : + case "Flash" : + case "MeteringMode" : + case "ExposureProgram" : + case "SensingMethod" : + case "SceneCaptureType" : + case "SceneType" : + case "CustomRendered" : + case "WhiteBalance" : + case "GainControl" : + case "Contrast" : + case "Saturation" : + case "Sharpness" : + case "SubjectDistanceRange" : + case "FileSource" : + exifData[tag] = StringValues[tag][exifData[tag]]; + break; + + case "ExifVersion" : + case "FlashpixVersion" : + exifData[tag] = String.fromCharCode(exifData[tag][0], exifData[tag][1], exifData[tag][2], exifData[tag][3]); + break; + + case "ComponentsConfiguration" : + exifData[tag] = + StringValues.Components[exifData[tag][0]] + + StringValues.Components[exifData[tag][1]] + + StringValues.Components[exifData[tag][2]] + + StringValues.Components[exifData[tag][3]]; + break; + } + tags[tag] = exifData[tag]; + } + } + + if (tags.GPSInfoIFDPointer) { + gpsData = readTags(file, tiffOffset, tiffOffset + tags.GPSInfoIFDPointer, GPSTags, bigEnd); + for (tag in gpsData) { + switch (tag) { + case "GPSVersionID" : + gpsData[tag] = gpsData[tag][0] + + "." + gpsData[tag][1] + + "." + gpsData[tag][2] + + "." + gpsData[tag][3]; + break; + } + tags[tag] = gpsData[tag]; + } + } + + // extract thumbnail + tags['thumbnail'] = readThumbnailImage(file, tiffOffset, firstIFDOffset, bigEnd); + + return tags; + } + + function findXMPinJPEG(file) { + + if (!('DOMParser' in self)) { + // console.warn('XML parsing not supported without DOMParser'); + return; + } + var dataView = new DataView(file); + + if (debug) console.log("Got file of length " + file.byteLength); + if ((dataView.getUint8(0) != 0xFF) || (dataView.getUint8(1) != 0xD8)) { + if (debug) console.log("Not a valid JPEG"); + return false; // not a valid jpeg + } + + var offset = 2, + length = file.byteLength, + dom = new DOMParser(); + + while (offset < (length-4)) { + if (getStringFromDB(dataView, offset, 4) == "http") { + var startOffset = offset - 1; + var sectionLength = dataView.getUint16(offset - 2) - 1; + var xmpString = getStringFromDB(dataView, startOffset, sectionLength) + var xmpEndIndex = xmpString.indexOf('xmpmeta>') + 8; + xmpString = xmpString.substring( xmpString.indexOf( '')) { + var domDocument = dom.parseFromString(extendedXmpString, 'text/xml'); + return xml2Object(domDocument); + } + } else { + offset++; + } + } + } + + function xml2json(xml) { + var json = {}; + + if (xml.nodeType == 1) { // element node + if (xml.attributes.length > 0) { + json['@attributes'] = {}; + for (var j = 0; j < xml.attributes.length; j++) { + var attribute = xml.attributes.item(j); + json['@attributes'][attribute.nodeName] = attribute.nodeValue; + } + } + } else if (xml.nodeType == 3) { // text node + return xml.nodeValue; + } + + // deal with children + if (xml.hasChildNodes()) { + for(var i = 0; i < xml.childNodes.length; i++) { + var child = xml.childNodes.item(i); + var nodeName = child.nodeName; + if (json[nodeName] == null) { + json[nodeName] = xml2json(child); + } else { + if (json[nodeName].push == null) { + var old = json[nodeName]; + json[nodeName] = []; + json[nodeName].push(old); + } + json[nodeName].push(xml2json(child)); + } + } + } + + return json; + } + + function xml2Object(xml) { + try { + var obj = {}; + if (xml.children.length > 0) { + for (var i = 0; i < xml.children.length; i++) { + var item = xml.children.item(i); + var attributes = item.attributes; + for(var idx in attributes) { + var itemAtt = attributes[idx]; + var dataKey = itemAtt.nodeName; + var dataValue = itemAtt.nodeValue; + + if(dataKey !== undefined) { + obj[dataKey] = dataValue; + } + } + var nodeName = item.nodeName; + + if (typeof (obj[nodeName]) == "undefined") { + obj[nodeName] = xml2json(item); + } else { + if (typeof (obj[nodeName].push) == "undefined") { + var old = obj[nodeName]; + + obj[nodeName] = []; + obj[nodeName].push(old); + } + obj[nodeName].push(xml2json(item)); + } + } + } else { + obj = xml.textContent; + } + return obj; + } catch (e) { + console.log(e.message); + } + } + + EXIF.enableXmp = function() { + EXIF.isXmpEnabled = true; + } + + EXIF.disableXmp = function() { + EXIF.isXmpEnabled = false; + } + + EXIF.getData = function(img, callback) { + if (((self.Image && img instanceof self.Image) + || (self.HTMLImageElement && img instanceof self.HTMLImageElement)) + && !img.complete) + return false; + + if (!imageHasData(img)) { + getImageData(img, callback); + } else { + if (callback) { + callback.call(img); + } + } + return true; + } + + EXIF.getTag = function(img, tag) { + if (!imageHasData(img)) return; + return img.exifdata[tag]; + } + + EXIF.getIptcTag = function(img, tag) { + if (!imageHasData(img)) return; + return img.iptcdata[tag]; + } + + EXIF.getAllTags = function(img) { + if (!imageHasData(img)) return {}; + var a, + data = img.exifdata, + tags = {}; + for (a in data) { + if (data.hasOwnProperty(a)) { + tags[a] = data[a]; + } + } + return tags; + } + + EXIF.getAllIptcTags = function(img) { + if (!imageHasData(img)) return {}; + var a, + data = img.iptcdata, + tags = {}; + for (a in data) { + if (data.hasOwnProperty(a)) { + tags[a] = data[a]; + } + } + return tags; + } + + EXIF.pretty = function(img) { + if (!imageHasData(img)) return ""; + var a, + data = img.exifdata, + strPretty = ""; + for (a in data) { + if (data.hasOwnProperty(a)) { + if (typeof data[a] == "object") { + if (data[a] instanceof Number) { + strPretty += a + " : " + data[a] + " [" + data[a].numerator + "/" + data[a].denominator + "]\r\n"; + } else { + strPretty += a + " : [" + data[a].length + " values]\r\n"; + } + } else { + strPretty += a + " : " + data[a] + "\r\n"; + } + } + } + return strPretty; + } + + EXIF.readFromBinaryFile = function(file) { + return findEXIFinJPEG(file); + } + + if (typeof define === 'function' && define.amd) { + define('exif-js', [], function() { + return EXIF; + }); + } +}.call(this)); + diff --git a/scripts/offline-support.js b/scripts/offline-support.js new file mode 100644 index 0000000..c2ff61f --- /dev/null +++ b/scripts/offline-support.js @@ -0,0 +1,10 @@ +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('service-worker.js') + .then(function (registration) { + console.log('ServiceWorker registration successful with scope: ', registration.scope); + }, function (error) { + console.log('ServiceWorker registration failed: ', error); + }); + }); +} diff --git a/service-worker.js b/service-worker.js new file mode 100644 index 0000000..f8f6e5d --- /dev/null +++ b/service-worker.js @@ -0,0 +1,46 @@ +const APP_NAME = '180-stereo-photo-viewer'; +const VERSION = '1.0.0'; +const CACHE_NAME = `${APP_NAME}-cache-v${VERSION}`; + +self.addEventListener('install', function (event) { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => { + return cache.addAll([ + '/', + '/index.html', + 'https://aframe.io/releases/0.9.0/aframe.min.js', + '/scripts/exif-js-v2.3.0-added-support-for-extended-xmp.js', + '/scripts/180-stereo-photo-viewer.js', + '/scripts/offline-support.js' + ] + ); + }) + ); +}); + +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys() + .then(cacheNames => { + return Promise.all(cacheNames.map(cacheName => { + if (cacheName.startsWith(APP_NAME) && cacheName !== CACHE_NAME) { + return caches.delete(cacheName); + } + })); + }) + ); +}); + +self.addEventListener('fetch', event => { + event.respondWith( + caches.match(event.request) + .then(response => { + if (response) { + return response; + } + + return fetch(event.request); + }) + ); +})