Viewer: Set native video stream src based on mimetype #1307 #3168 #4698

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2025-01-27 13:21:05 +01:00
parent 8c3ec7435e
commit 420fa9946c
37 changed files with 770 additions and 393 deletions

View file

@ -23,25 +23,33 @@ Additional information can be found in our Developer Guide:
*/
export const ContentTypeAVC = 'video/mp4; codecs="avc1"';
export const ContentTypeHEVC = 'video/mp4; codecs="hvc1.1.6.L93.90"';
export const ContentTypeOGG = "video/ogg";
export const ContentTypeWebM = "video/webm";
export const ContentTypeVP8 = 'video/webm; codecs="vp8"';
export const ContentTypeVP9 = 'video/webm; codecs="vp9"';
export const ContentTypeAV1 = 'video/webm; codecs="av01.0.08M.08"';
export const canUseVideo = !!document.createElement("video").canPlayType;
export const canUseAvc = canUseVideo // AVC
? !!document.createElement("video").canPlayType('video/mp4; codecs="avc1"')
? !!document.createElement("video").canPlayType(ContentTypeAVC)
: false;
export const canUseHevc = canUseVideo // HEVC, Basic Support
? !!document.createElement("video").canPlayType(ContentTypeHEVC)
: false;
export const canUseOGV = canUseVideo // Ogg Theora
? !!document.createElement("video").canPlayType("video/ogg")
? !!document.createElement("video").canPlayType(ContentTypeOGG)
: false;
export const canUseVP8 = canUseVideo // Google WebM, VP8
? !!document.createElement("video").canPlayType('video/webm; codecs="vp8"')
? !!document.createElement("video").canPlayType(ContentTypeVP8)
: false;
export const canUseVP9 = canUseVideo // Google WebM, VP9
? !!document.createElement("video").canPlayType('video/webm; codecs="vp9"')
? !!document.createElement("video").canPlayType(ContentTypeVP9)
: false;
export const canUseAv1 = canUseVideo // AV1, Main Profile
? !!document.createElement("video").canPlayType('video/webm; codecs="av01.0.08M.08"')
? !!document.createElement("video").canPlayType(ContentTypeAV1)
: false;
export const canUseWebM = canUseVideo // Google WebM
? !!document.createElement("video").canPlayType("video/webm")
: false;
export const canUseHevc = canUseVideo // HVC, Basic Support
? !!document.createElement("video").canPlayType('video/mp4; codecs="hvc1.1.6.L93.90"')
? !!document.createElement("video").canPlayType(ContentTypeWebM)
: false;

View file

@ -23,7 +23,21 @@ Additional information can be found in our Developer Guide:
*/
import { canUseAv1, canUseHevc, canUseOGV, canUseVP8, canUseVP9, canUseWebM } from "./caniuse";
import {
canUseAv1,
canUseHevc,
canUseOGV,
canUseVP8,
canUseVP9,
canUseWebM,
ContentTypeAVC,
ContentTypeHEVC,
ContentTypeOGG,
ContentTypeWebM,
ContentTypeVP8,
ContentTypeVP9,
ContentTypeAV1,
} from "./caniuse";
import { config } from "app/session";
import {
DATE_FULL,
@ -34,10 +48,11 @@ import {
CodecOGV,
CodecVP8,
CodecVP9,
FormatAv1,
FormatAvc,
FormatHevc,
FormatAV1,
FormatAVC,
FormatHEVC,
FormatWebM,
CodecOGG,
} from "model/photo";
import sanitizeHtml from "sanitize-html";
import { DateTime } from "luxon";
@ -545,46 +560,59 @@ export default class Util {
return "fit_7680";
}
static videoFormat(codec) {
if (!codec) {
return FormatAvc;
} else if (canUseHevc && (codec === CodecHvc1 || codec === CodecHev1)) {
return FormatHevc;
} else if (canUseOGV && codec === CodecOGV) {
static videoFormat(codec, mime) {
if (!codec && !mime) {
return FormatAVC;
} else if (canUseHevc && (codec === CodecHvc1 || codec === CodecHev1 || mime === ContentTypeHEVC)) {
return FormatHEVC;
} else if (canUseOGV && (codec === CodecOGV || codec === CodecOGG || mime === ContentTypeOGG)) {
return CodecOGV;
} else if (canUseVP8 && codec === CodecVP8) {
} else if (canUseVP8 && (codec === CodecVP8 || mime === ContentTypeVP8)) {
return CodecVP8;
} else if (canUseVP9 && codec === CodecVP9) {
} else if (canUseVP9 && (codec === CodecVP9 || mime === ContentTypeVP9)) {
return CodecVP9;
} else if (canUseAv1 && (codec === CodecAv01 || codec === CodecAv1C)) {
return FormatAv1;
} else if (canUseWebM && codec === FormatWebM) {
} else if (canUseAv1 && (codec === CodecAv01 || codec === CodecAv1C || mime === ContentTypeAV1)) {
return FormatAV1;
} else if (canUseWebM && (codec === FormatWebM || mime === ContentTypeWebM)) {
return FormatWebM;
}
return FormatAvc;
return FormatAVC;
}
static videoUrl(hash, codec) {
return `${config.videoUri}/videos/${hash}/${config.previewToken}/${this.videoFormat(codec)}`;
static videoFormatUrl(hash, format) {
if (!hash) {
return "";
}
if (!format) {
format = FormatAVC;
}
return `${config.videoUri}/videos/${hash}/${config.previewToken}/${format}`;
}
static videoType(codec) {
switch (this.videoFormat(codec)) {
case FormatAvc:
return 'video/mp4; codecs="avc1"';
static videoUrl(hash, codec, mime) {
return this.videoFormatUrl(hash, this.videoFormat(codec, mime));
}
static videoContentType(codec, mime) {
switch (this.videoFormat(codec, mime)) {
case FormatAVC:
return ContentTypeAVC;
case CodecOGV:
return "video/ogg";
return ContentTypeOGG;
case CodecVP8:
return 'video/webm; codecs="vp8"';
return ContentTypeVP8;
case CodecVP9:
return 'video/webm; codecs="vp9"';
case FormatAv1:
return 'video/webm; codecs="av01.0.08M.08"';
return ContentTypeVP9;
case FormatAV1:
return ContentTypeAV1;
case FormatWebM:
return "video/webm";
case FormatHevc:
return 'video/mp4; codecs="hvc1.1.6.L93.90"';
return ContentTypeWebM;
case FormatHEVC:
return ContentTypeHEVC;
default:
return "video/mp4";
}

View file

@ -183,7 +183,7 @@
<td>
{{ $gettext(`Type`) }}
</td>
<td class="text-break">{{ file.typeInfo() }}</td>
<td class="text-break" :title="file?.Mime">{{ file.typeInfo() }}</td>
</tr>
<tr v-if="file.isAnimated()">
<td>

View file

@ -119,7 +119,7 @@
<div class="preview__overlay"></div>
<div v-if="m.Type === 'live' || m.Type === 'animated'" class="live-player">
<video :id="'live-player-' + m.ID" width="500" height="500" preload="none" loop muted playsinline>
<source :type="m.videoType()" :src="m.videoUrl()" />
<source :type="m.videoContentType()" :src="m.videoUrl()" />
</video>
</div>

View file

@ -70,7 +70,7 @@
<div class="preview__overlay"></div>
<div v-if="m.Type === 'live' || m.Type === 'animated'" class="live-player">
<video :id="'live-player-' + m.ID" width="224" height="224" preload="none" loop muted playsinline>
<source :type="m.videoType()" :src="m.videoUrl()" />
<source :type="m.videoContentType()" :src="m.videoUrl()" />
</video>
</div>

View file

@ -24,7 +24,8 @@ import PhotoSwipeDynamicCaption from "photoswipe-dynamic-caption-plugin";
import Util from "common/util";
import Api from "common/api";
import Thumb from "model/thumb";
import { MediaAnimated, MediaLive, Photo } from "model/photo";
import { FormatAVC, MediaAnimated, MediaLive, Photo } from "model/photo";
import { ContentTypeAVC } from "common/caniuse";
/*
TODO: All previously available features and controls must be preserved in the new hybrid photo/video viewer:
@ -218,6 +219,116 @@ export default {
ctx.viewer.loading = false;
});
},
getItemData(el, i) {
/*
TODO: Rendering of slides needs to be improved to allow dynamic zooming (loading higher resolution thumbs
depending on zoom level and screen resolution).
*/
// Get the current slide model data.
const model = this.models[i];
// Get the screen (window) resolution in real pixels
const pixels = this.getWindowPixels();
// Get the right thumbnail size based on the screen resolution in pixels.
const thumbSize = Util.thumbSize(pixels.width, pixels.height);
// Get thumbnail image URL, width, and height.
const img = {
src: model.Thumbs[thumbSize].src,
width: model.Thumbs[thumbSize].w,
height: model.Thumbs[thumbSize].h,
alt: model?.Title,
};
// Check if content is playable and return the data needed to render it in "contentLoad".
if (model?.Playable && model?.Hash) {
/*
TODO: The server should (additionally) provide a video/animation still from time index 0 that can be used as
poster (the current thumbnail is taken later for longer videos, since the first frame is often black).
*/
// Check if the video duration is known and 5 seconds or less.
const isShort = model?.Duration ? model.Duration > 0 && model.Duration <= 5000000000 : false;
// Set the slide data needed to render and play the video.
return {
type: "html", // Render custom HTML.
html: `<div class="pswp__error-msg">Loading video...</div>`, // Replaced with the <video> element.
model: model, // Content model.
format: Util.videoFormat(model?.Codec, model?.Mime), // Content format.
loop: isShort || model?.Type === MediaAnimated || model?.Type === MediaLive, // If possible, loop these types.
msrc: img.src, // Image URL.
};
}
// Return the image data so that PhotoSwipe can render it in the viewer,
// see https://photoswipe.com/data-sources/#dynamically-generated-data.
return img;
},
onContentLoad(ev) {
const { content } = ev;
if (content.data?.type === "html") {
// Prevent default loading behavior.
ev.preventDefault();
try {
// Create video element.
content.element = this.createVideoElement(
content.data.model,
content.data.format,
content.data.msrc,
false,
false,
false
);
content.state = "loading";
content.onLoaded();
} catch (err) {
console.warn("failed to load video", err);
}
}
},
// Creates an HTMLMediaElement for playing videos, animations, and live photos.
createVideoElement(model, format, posterSrc, autoplay = false, loop = false, mute = false) {
// See https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement.
const video = document.createElement("video");
// Check if a slideshow is running.
const slideshow = this.slideshow.active;
// Set HTMLMediaElement properties.
video.className = "pswp__video";
video.poster = posterSrc;
video.autoplay = autoplay;
video.loop = loop && !slideshow;
video.mute = mute;
video.preload = autoplay ? "auto" : "metadata";
video.playsInline = true;
video.controls = true;
// Disable download control is downloads are not allowed.
if (!this.canDownload && video.controlsList) {
video.controlsList?.add("nodownload");
}
// Create and append video source elements, depending on file format support.
if (format !== FormatAVC && model?.Mime && model.Mime !== ContentTypeAVC && video.canPlayType(model.Mime)) {
const nativeSource = document.createElement("source");
nativeSource.type = model.Mime;
nativeSource.src = Util.videoFormatUrl(model.Hash, format);
video.appendChild(nativeSource);
}
const avcSource = document.createElement("source");
avcSource.type = ContentTypeAVC;
avcSource.src = Util.videoFormatUrl(model.Hash, FormatAVC);
video.appendChild(avcSource);
// Return HTMLMediaElement.
return video;
},
// Initializes and opens the PhotoSwipe lightbox with the
// images and/or videos that belong to the specified models.
renderLightbox(models, index = 0) {
@ -293,81 +404,11 @@ export default {
// Processes model data for rendering slides with PhotoSwipe,
// see https://photoswipe.com/filters/#itemdata.
lightbox.addFilter("itemData", (el, i) => {
/*
TODO: Rendering of slides needs to be improved to allow dynamic zooming (loading higher resolution thumbs
depending on zoom level and screen resolution).
*/
// Get the current slide model data.
const model = this.models[i];
// Get the screen (window) resolution in real pixels
const pixels = this.getWindowPixels();
// Get the right thumbnail size based on the screen resolution in pixels.
const thumbSize = Util.thumbSize(pixels.width, pixels.height);
// Get thumbnail image URL, width, and height.
const img = {
src: model.Thumbs[thumbSize].src,
width: model.Thumbs[thumbSize].w,
height: model.Thumbs[thumbSize].h,
alt: model?.Title,
};
// Check if content is playable and return the data needed to render it in "contentLoad".
if (model?.Playable && model?.Hash) {
/*
TODO: The server should (additionally) provide a video/animation still from time index 0 that can be used as
poster (the current thumbnail is taken later for longer videos, since the first frame is often black).
*/
// Check if the video duration is known and 5 seconds or less.
const isShort = model?.Duration ? model.Duration > 0 && model.Duration <= 5000000000 : false;
// Set the slide data needed to render and play the video.
return {
type: "html", // Render custom HTML.
html: `<div class="pswp__error-msg">Loading video...</div>`, // Replaced with the <video> element.
model: model, // Thumbnail model.
loop: isShort || model?.Type === MediaAnimated || model?.Type === MediaLive, // If possible, loop these types.
videoUrl: Util.videoUrl(model.Hash, model?.Codec), // Video URL.
videoType: Util.videoType(model?.Codec), // Media Type.
msrc: img.src, // Image URL.
};
}
// Return the image data so that PhotoSwipe can render it in the viewer,
// see https://photoswipe.com/data-sources/#dynamically-generated-data.
return img;
});
lightbox.addFilter("itemData", this.getItemData);
// Renders content when a slide starts to load (can be default prevented),
// see https://photoswipe.com/events/#slide-content-events.
lightbox.on("contentLoad", (ev) => {
const { content } = ev;
if (content.data?.type === "html") {
// Prevent default loading behavior.
ev.preventDefault();
try {
// Create video element.
content.element = this.createVideoElement(
content.data.videoUrl,
content.data.videoType,
content.data.msrc,
false,
false,
false
);
content.state = "loading";
content.onLoaded();
} catch (err) {
console.warn("failed to load video", err);
}
}
});
lightbox.on("contentLoad", this.onContentLoad);
// Pauses videos, animations, and live photos when slide content becomes active (can be default prevented),
// see https://photoswipe.com/events/#slide-content-events.
@ -562,43 +603,6 @@ export default {
}
});
},
// Creates an HTMLMediaElement for playing videos, animations, and live photos.
createVideoElement(videoSrc, videoType, posterSrc, autoplay = false, loop = false, mute = false) {
// See https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement.
const video = document.createElement("video");
// Check if a slideshow is running.
const slideshow = this.slideshow.active;
// Set HTMLMediaElement properties.
video.className = "pswp__video";
video.poster = posterSrc;
video.autoplay = autoplay;
video.loop = loop && !slideshow;
video.mute = mute;
video.preload = autoplay ? "auto" : "metadata";
video.playsInline = true;
video.controls = true;
// Disable download control is downloads are not allowed.
if (!this.canDownload && video.controlsList) {
video.controlsList?.add("nodownload");
}
// Create and append video source element.
/*
TODO: Provide alternative sources and/or an m3u8 playlist to let the browser choose the best format (first check
best practices on Google and in the code of other open source software, then develop a simple/static proof
of concept to see if/how it works).
*/
const source = document.createElement("source");
source.type = videoType;
source.src = videoSrc;
video.appendChild(source);
// Return HTMLMediaElement.
return video;
},
// Destroys the PhotoSwipe lightbox instance after use, see onClose().
destroyLightbox() {
if (this.lightbox) {

View file

@ -37,9 +37,10 @@ import { $gettext } from "common/gettext";
import { PhotoClipboard } from "common/clipboard";
import download from "common/download";
import * as src from "common/src";
import { canUseOGV, canUseVP8, canUseVP9, canUseAv1, canUseWebM, canUseHevc } from "common/caniuse";
import { ContentTypeAVC } from "common/caniuse";
export const CodecOGV = "ogv";
export const CodecOGG = "ogg";
export const CodecVP8 = "vp8";
export const CodecVP9 = "vp9";
export const CodecAv01 = "av01";
@ -47,15 +48,15 @@ export const CodecAv1C = "av1c";
export const CodecAvc1 = "avc1";
export const CodecHvc1 = "hvc1";
export const CodecHev1 = "hev1";
export const FormatMp4 = "mp4";
export const FormatAv1 = "av01";
export const FormatAvc = "avc";
export const FormatHevc = "hevc";
export const FormatMP4 = "mp4";
export const FormatAV1 = "av01";
export const FormatAVC = "avc";
export const FormatHEVC = "hevc";
export const FormatWebM = "webm";
export const FormatJpeg = "jpg";
export const FormatPng = "png";
export const FormatSvg = "svg";
export const FormatGif = "gif";
export const FormatJPEG = "jpg";
export const FormatPNG = "png";
export const FormatSVG = "svg";
export const FormatGIF = "gif";
export const MediaImage = "image";
export const MediaRaw = "raw";
export const MediaAnimated = "animated";
@ -200,7 +201,14 @@ export class Photo extends RestModel {
}
classes() {
return this.generateClasses(this.isPlayable(), PhotoClipboard.has(this), this.Portrait, this.Favorite, this.Private, this.isStack());
return this.generateClasses(
this.isPlayable(),
PhotoClipboard.has(this),
this.Portrait,
this.Favorite,
this.Private,
this.isStack()
);
}
generateClasses = memoizeOne((isPlayable, isInClipboard, portrait, favorite, isPrivate, isStack) => {
@ -441,7 +449,7 @@ export class Photo extends RestModel {
let jpegs = 0;
this.Files.forEach((f) => {
if (f && f.FileType === FormatJpeg) {
if (f && f.FileType === FormatJPEG) {
jpegs++;
}
});
@ -515,7 +523,7 @@ export class Photo extends RestModel {
let file = files.find((f) => f.Codec === CodecAvc1);
if (!file) {
file = files.find((f) => f.FileType === FormatMp4);
file = files.find((f) => f.FileType === FormatMP4);
}
if (!file) {
@ -534,39 +542,23 @@ export class Photo extends RestModel {
return false;
}
return this.Files.find((f) => f.FileType === FormatGif || !!f.Frames || !!f.Duration);
return this.Files.find((f) => f.FileType === FormatGIF || !!f.Frames || !!f.Duration);
}
videoContentType() {
const file = this.videoFile();
if (file) {
return Util.videoContentType(file?.Codec, file?.Mime);
} else {
return ContentTypeAVC;
}
}
videoUrl() {
const file = this.videoFile();
if (file) {
let videoFormat = FormatAvc;
const fileCodec = file.Codec ? file.Codec : "";
if (canUseHevc && (fileCodec === CodecHvc1 || fileCodec === CodecHev1)) {
videoFormat = FormatHevc;
} else if (canUseOGV && fileCodec === CodecOGV) {
videoFormat = CodecOGV;
} else if (canUseVP8 && fileCodec === CodecVP8) {
videoFormat = CodecVP8;
} else if (canUseVP9 && fileCodec === CodecVP9) {
videoFormat = CodecVP9;
} else if (canUseAv1 && (fileCodec === CodecAv01 || fileCodec === CodecAv1C)) {
videoFormat = FormatAv1;
} else if (canUseWebM && file.FileType === FormatWebM) {
videoFormat = FormatWebM;
}
return `${config.videoUri}/videos/${file.Hash}/${config.previewToken}/${videoFormat}`;
}
return `${config.videoUri}/videos/${this.Hash}/${config.previewToken}/${FormatAvc}`;
}
videoType() {
const file = this.videoFile();
return Util.videoType(file?.Codec);
return Util.videoUrl(file ? file.Hash : this.Hash, file?.Codec, file?.Mime);
}
mainFile() {
@ -587,7 +579,7 @@ export class Photo extends RestModel {
}
// Find and return the first JPEG or PNG image otherwise.
file = files.find((f) => f.FileType === FormatJpeg || f.FileType === FormatPng);
file = files.find((f) => f.FileType === FormatJPEG || f.FileType === FormatPNG);
// Found?
if (file) {
@ -636,7 +628,7 @@ export class Photo extends RestModel {
}
// Find first original media file with a format other than JPEG.
file = files.find((f) => !f.Sidecar && f.FileType !== FormatJpeg && f.Root === "/");
file = files.find((f) => !f.Sidecar && f.FileType !== FormatJPEG && f.Root === "/");
// Found?
if (file) {
@ -652,7 +644,7 @@ export class Photo extends RestModel {
return [this];
}
return this.Files.filter((f) => f.FileType === FormatJpeg || f.FileType === FormatPng);
return this.Files.filter((f) => f.FileType === FormatJPEG || f.FileType === FormatPNG);
}
mainFileHash() {
@ -696,7 +688,14 @@ export class Photo extends RestModel {
}
thumbnailUrl(size) {
return this.generateThumbnailUrl(this.mainFileHash(), this.videoFile(), config.staticUri, config.contentUri, config.previewToken, size);
return this.generateThumbnailUrl(
this.mainFileHash(),
this.videoFile(),
config.staticUri,
config.contentUri,
config.previewToken,
size
);
}
generateThumbnailUrl = memoizeOne((mainFileHash, videoFile, staticUri, contentUri, previewToken, size) => {
@ -900,7 +899,7 @@ export class Photo extends RestModel {
return this;
}
return this.Files.find((f) => f.MediaType === MediaVector || f.FileType === FormatSvg);
return this.Files.find((f) => f.MediaType === MediaVector || f.FileType === FormatSVG);
}
getVectorInfo = () => {
@ -1010,48 +1009,60 @@ export class Photo extends RestModel {
// Example: iPhone 12 Pro Max 5.1mm ƒ/1.6, 26mm, ISO32, 1/4525
getLensInfo = () => {
return this.generateLensInfo(this.Lens, this.LensID, this.LensMake, this.LensModel, this.CameraModel, this.FNumber, this.Iso, this.Exposure, this.FocalLength);
return this.generateLensInfo(
this.Lens,
this.LensID,
this.LensMake,
this.LensModel,
this.CameraModel,
this.FNumber,
this.Iso,
this.Exposure,
this.FocalLength
);
};
generateLensInfo = memoizeOne((lens, lensId, lensMake, lensModel, cameraModel, fNumber, iso, exposure, focalLength) => {
let info = [];
const id = lensId ? lensId : lens && lens.ID ? lens.ID : 1;
const make = lensMake ? lensMake : lens && lens.Make ? lens.Make : "";
const model = (lensModel ? lensModel : lens && lens.Model ? lens.Model : "").replace("f/", "ƒ/");
generateLensInfo = memoizeOne(
(lens, lensId, lensMake, lensModel, cameraModel, fNumber, iso, exposure, focalLength) => {
let info = [];
const id = lensId ? lensId : lens && lens.ID ? lens.ID : 1;
const make = lensMake ? lensMake : lens && lens.Make ? lens.Make : "";
const model = (lensModel ? lensModel : lens && lens.Model ? lens.Model : "").replace("f/", "ƒ/");
// Example: EF-S18-55mm f/3.5-5.6 IS STM
if (id > 1) {
if (!model && !!make) {
info.push(make);
} else if (model.length > 45) {
return model;
} else if (model) {
info.push(model);
// Example: EF-S18-55mm f/3.5-5.6 IS STM
if (id > 1) {
if (!model && !!make) {
info.push(make);
} else if (model.length > 45) {
return model;
} else if (model) {
info.push(model);
}
}
}
if (focalLength) {
info.push(focalLength + "mm");
}
if (focalLength) {
info.push(focalLength + "mm");
}
if (fNumber && (!model || !model.endsWith(fNumber.toString()))) {
info.push("ƒ/" + fNumber);
}
if (fNumber && (!model || !model.endsWith(fNumber.toString()))) {
info.push("ƒ/" + fNumber);
}
if (iso && model.length < 27) {
info.push("ISO " + iso);
}
if (iso && model.length < 27) {
info.push("ISO " + iso);
}
if (exposure) {
info.push(exposure);
}
if (exposure) {
info.push(exposure);
}
if (!info.length) {
return $gettext("Unknown");
}
if (!info.length) {
return $gettext("Unknown");
}
return info.join(", ");
});
return info.join(", ");
}
);
getCamera() {
if (this.Camera) {
@ -1091,15 +1102,21 @@ export class Photo extends RestModel {
}
primaryFile(fileUID) {
return Api.post(`${this.getEntityResource()}/files/${fileUID}/primary`).then((r) => Promise.resolve(this.setValues(r.data)));
return Api.post(`${this.getEntityResource()}/files/${fileUID}/primary`).then((r) =>
Promise.resolve(this.setValues(r.data))
);
}
unstackFile(fileUID) {
return Api.post(`${this.getEntityResource()}/files/${fileUID}/unstack`).then((r) => Promise.resolve(this.setValues(r.data)));
return Api.post(`${this.getEntityResource()}/files/${fileUID}/unstack`).then((r) =>
Promise.resolve(this.setValues(r.data))
);
}
deleteFile(fileUID) {
return Api.delete(`${this.getEntityResource()}/files/${fileUID}`).then((r) => Promise.resolve(this.setValues(r.data)));
return Api.delete(`${this.getEntityResource()}/files/${fileUID}`).then((r) =>
Promise.resolve(this.setValues(r.data))
);
}
changeFileOrientation(file) {
@ -1117,7 +1134,9 @@ export class Photo extends RestModel {
}
// Change file orientation.
return Api.put(`${this.getEntityResource()}/files/${file.UID}/orientation`, values).then((r) => Promise.resolve(this.setValues(r.data)));
return Api.put(`${this.getEntityResource()}/files/${file.UID}/orientation`, values).then((r) =>
Promise.resolve(this.setValues(r.data))
);
}
like() {
@ -1131,15 +1150,21 @@ export class Photo extends RestModel {
}
addLabel(name) {
return Api.post(this.getEntityResource() + "/label", { Name: name, Priority: 10 }).then((r) => Promise.resolve(this.setValues(r.data)));
return Api.post(this.getEntityResource() + "/label", { Name: name, Priority: 10 }).then((r) =>
Promise.resolve(this.setValues(r.data))
);
}
activateLabel(id) {
return Api.put(this.getEntityResource() + "/label/" + id, { Uncertainty: 0 }).then((r) => Promise.resolve(this.setValues(r.data)));
return Api.put(this.getEntityResource() + "/label/" + id, { Uncertainty: 0 }).then((r) =>
Promise.resolve(this.setValues(r.data))
);
}
renameLabel(id, name) {
return Api.put(this.getEntityResource() + "/label/" + id, { Label: { Name: name } }).then((r) => Promise.resolve(this.setValues(r.data)));
return Api.put(this.getEntityResource() + "/label/" + id, { Label: { Name: name } }).then((r) =>
Promise.resolve(this.setValues(r.data))
);
}
removeLabel(id) {

View file

@ -43,6 +43,8 @@ export class Thumb extends Model {
Width: 0,
Height: 0,
Hash: "",
Codec: "",
Mime: "",
Thumbs: {},
};
}

View file

@ -1,5 +1,6 @@
import "../fixtures";
import Util from "common/util";
import {canUseHevc, canUseWebM} from "common/caniuse";
let chai = require("chai/chai");
let assert = chai.assert;
@ -45,6 +46,24 @@ describe("common/util", () => {
const iPhone13 = Util.formatCamera(null, 21, "Apple", "iPhone 13");
assert.equal(iPhone13, "iPhone 13");
});
it("should return matching video format name", () => {
const avc = Util.videoFormat("avc1", "video/mp4");
assert.equal(avc, "avc");
const hvc = Util.videoFormat("hvc1", "video/mp4");
if (canUseHevc) {
assert.equal(hvc, "hvc");
} else {
assert.equal(hvc, "avc");
}
const webm = Util.videoFormat("", "video/webm");
if (canUseWebM) {
assert.equal(webm, "webm");
} else {
assert.equal(webm, "avc");
}
});
it("should convert -1 to roman", () => {
const roman = Util.arabicToRoman(-1);
assert.equal(roman, "");
@ -75,6 +94,9 @@ describe("common/util", () => {
});
it("should encode link", () => {
const result = Util.encodeHTML("Try this: https://photoswipe.com/options/?foo=bar&bar=baz. It's a link!");
assert.equal(result, `Try this: <a href="https://photoswipe.com/options/" target="_blank">https://photoswipe.com/options/</a> It&apos;s a link!`);
assert.equal(
result,
`Try this: <a href="https://photoswipe.com/options/" target="_blank">https://photoswipe.com/options/</a> It&apos;s a link!`
);
});
});

View file

@ -1,5 +1,6 @@
import "../fixtures";
import { Photo, BatchSize, FormatJpeg } from "model/photo";
import { Photo, BatchSize, FormatJPEG } from "model/photo";
import { ContentTypeAVC } from "common/caniuse";
let chai = require("chai/chai");
let assert = chai.assert;
@ -505,7 +506,7 @@ describe("model/photo", () => {
FileType: "TypeJpeg",
Width: 500,
Height: 600,
Hash: "1xxbgdt53",
Hash: "ca3e60b9825bd61ee6369fcefe22f4eb92631bb5",
},
],
};
@ -536,7 +537,7 @@ describe("model/photo", () => {
FileType: "TypeJpeg",
Width: 500,
Height: 600,
Hash: "1xxbgdt53",
Hash: "ca3e60b9825bd61ee6369fcefe22f4eb92631bb5",
},
],
};
@ -547,7 +548,7 @@ describe("model/photo", () => {
photo.refreshFileAttr();
assert.equal(photo.Width, 500);
assert.equal(photo.Height, 600);
assert.equal(photo.Hash, "1xxbgdt53");
assert.equal(photo.Hash, "ca3e60b9825bd61ee6369fcefe22f4eb92631bb5");
});
it("should return is playable", () => {
@ -564,7 +565,7 @@ describe("model/photo", () => {
FileType: "TypeJpeg",
Width: 500,
Height: 600,
Hash: "1xxbgdt53",
Hash: "ca3e60b9825bd61ee6369fcefe22f4eb92631bb5",
},
],
};
@ -587,7 +588,7 @@ describe("model/photo", () => {
FileType: "mp4",
Width: 500,
Height: 600,
Hash: "1xxbgdt55",
Hash: "c1e30d265eab968155082c8e86d85815a8389479",
},
],
};
@ -607,7 +608,7 @@ describe("model/photo", () => {
FileType: "jpg",
Width: 500,
Height: 600,
Hash: "1xxbgdt53",
Hash: "ca3e60b9825bd61ee6369fcefe22f4eb92631bb5",
Codec: "avc1",
},
],
@ -631,7 +632,7 @@ describe("model/photo", () => {
FileType: "mp4",
Width: 900,
Height: 600,
Hash: "1xxbgdt55",
Hash: "c1e30d265eab968155082c8e86d85815a8389479",
},
],
};
@ -640,7 +641,7 @@ describe("model/photo", () => {
assert.isAbove(result.height, 340);
assert.isAbove(result.width, 510);
assert.equal(result.loop, false);
assert.equal(result.uri, "/api/v1/videos/1xxbgdt55/public/avc");
assert.equal(result.uri, "/api/v1/videos/c1e30d265eab968155082c8e86d85815a8389479/public/avc");
const values = {
ID: 11,
UID: "ABC127",
@ -655,7 +656,7 @@ describe("model/photo", () => {
FileType: "mp4",
Width: 0,
Height: 0,
Hash: "1xxbgdt55",
Hash: "c1e30d265eab968155082c8e86d85815a8389479",
},
{
UID: "123fpp",
@ -663,7 +664,7 @@ describe("model/photo", () => {
Primary: true,
Width: 5000,
Height: 5000,
Hash: "1xxbgdt544",
Hash: "ca3e60b9825bd61ee6369fcefe22f4eb92631bb5",
},
],
};
@ -672,7 +673,7 @@ describe("model/photo", () => {
assert.isAbove(result2.height, 340);
assert.isAbove(result2.width, 340);
assert.equal(result2.loop, false);
assert.equal(result2.uri, "/api/v1/videos/1xxbgdt55/public/avc");
assert.equal(result2.uri, "/api/v1/videos/c1e30d265eab968155082c8e86d85815a8389479/public/avc");
});
it("should return videofile", () => {
@ -689,7 +690,7 @@ describe("model/photo", () => {
FileType: "jpg",
Width: 500,
Height: 600,
Hash: "1xxbgdt53",
Hash: "c1e30d265eab968155082c8e86d85815a8389479",
},
],
};
@ -711,7 +712,7 @@ describe("model/photo", () => {
FileType: "mp4",
Width: 500,
Height: 600,
Hash: "1xxbgdt55",
Hash: "c1e30d265eab968155082c8e86d85815a8389479",
},
],
};
@ -761,13 +762,11 @@ describe("model/photo", () => {
],
};
const photo = new Photo(values);
assert.equal(photo.videoContentType(), ContentTypeAVC);
assert.equal(photo.videoUrl(), "/api/v1/videos/703cf8f274fbb265d49c6262825780e1/public/avc");
const values2 = { ID: 9, UID: "ABC163", Hash: "2305e512e3b183ec982d60a8b608a8ca501973ba" };
const photo2 = new Photo(values2);
assert.equal(
photo2.videoUrl(),
"/api/v1/videos/2305e512e3b183ec982d60a8b608a8ca501973ba/public/avc"
);
assert.equal(photo2.videoUrl(), "/api/v1/videos/2305e512e3b183ec982d60a8b608a8ca501973ba/public/avc");
const values3 = {
ID: 10,
UID: "ABC127",
@ -886,7 +885,7 @@ describe("model/photo", () => {
UID: "123fgb",
Name: "1980/01/superCuteKitten.jpg",
Primary: false,
FileType: FormatJpeg,
FileType: FormatJPEG,
Width: 500,
Height: 600,
Hash: "1xxbgdt55",

View file

@ -1,7 +1,6 @@
package api
import (
"fmt"
"net/http"
"path/filepath"
"strings"
@ -111,7 +110,7 @@ func GetVideo(router *gin.RouterGroup) {
} else {
// Serve embedded videos from cache to allow streaming and transcoding.
videoBitrate = info.VideoBitrate()
videoCodec = info.VideoCodec.String()
videoCodec = info.VideoCodec
videoFileType = info.VideoFileType().String()
videoFileName = cacheName
log.Debugf("video: streaming %s encoded %s in %s from cache", strings.ToUpper(videoCodec), strings.ToUpper(videoFileType), clean.Log(f.FileName))
@ -119,7 +118,7 @@ func GetVideo(router *gin.RouterGroup) {
}
// Check video format support.
supported := videoCodec != "" && videoCodec == format.Codec.String() || format.Codec == video.CodecUnknown && videoFileType == format.FileType.String()
supported := videoCodec != "" && videoCodec == format.Codec || format.Codec == video.CodecUnknown && videoFileType == format.FileType.String()
// Check video bitrate against the configured limit.
transcode := !supported || conf.FFmpegEnabled() && conf.FFmpegBitrateExceeded(videoBitrate)
@ -153,10 +152,10 @@ func GetVideo(router *gin.RouterGroup) {
} else {
if videoCodec != "" && videoCodec != videoFileType {
log.Debugf("video: %s is %s encoded and requires no transcoding, average bitrate %.1f MBit/s", clean.Log(f.FileName), strings.ToUpper(videoCodec), videoBitrate)
AddContentTypeHeader(c, fmt.Sprintf("%s; codecs=\"%s\"", f.FileMime, clean.Codec(videoCodec)))
AddContentTypeHeader(c, f.ContentType())
} else {
log.Debugf("video: %s is streamed directly, average bitrate %.1f MBit/s", clean.Log(f.FileName), videoBitrate)
AddContentTypeHeader(c, f.FileMime)
AddContentTypeHeader(c, f.ContentType())
}
}

View file

@ -6,9 +6,14 @@ import (
"math"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/photoprism/photoprism/pkg/media/video"
"github.com/photoprism/photoprism/pkg/header"
"github.com/dustin/go-humanize/english"
"github.com/gosimple/slug"
"github.com/jinzhu/gorm"
@ -880,3 +885,49 @@ func (m *File) SetOrientation(val int, src string) *File {
return m
}
// ContentType returns the file content type including codec if known.
func (m *File) ContentType() (contentType string) {
contentType = m.FileMime
if m.FileVideo {
if contentType == "" {
var codec string
if m.FileCodec != "" {
codec = m.FileCodec
} else {
codec = m.FileType
}
switch codec {
case "mov", "mp4", "avc", video.CodecAVC:
contentType = header.ContentTypeAVC
case video.CodecHEVC:
contentType = header.ContentTypeHEVC
case video.CodecVP8, "vp08":
contentType = header.ContentTypeVP8
case video.CodecVP9, "vp09":
contentType = header.ContentTypeVP9
case "av1", "av01":
contentType = header.ContentTypeAV1
case "ogg":
contentType = header.ContentTypeOGG
case "webm":
contentType = header.ContentTypeWebM
}
}
if contentType != "" && !strings.Contains(contentType, ";") {
if codec := clean.Codec(m.FileCodec); codec != "" {
contentType = fmt.Sprintf("%s; codecs=\"%s\"", contentType, codec)
}
}
}
contentType = clean.ContentType(contentType)
log.Debugf("file: %s has content type %s", clean.Log(m.FileName), clean.LogQuote(contentType))
return contentType
}

View file

@ -1133,7 +1133,7 @@ var FileFixtures = FileMap{
OriginalName: "my-videos/IMG_88888.MP4",
FileHash: "pcad9a68fa6acc5c5ba965adf6ec465ca42fd915",
FileSize: 900,
FileCodec: "avc1",
FileCodec: "hvc1",
FileType: "mp4",
MediaType: media.Video.String(),
FileMime: "video/mp4",

View file

@ -4,6 +4,8 @@ import (
"testing"
"time"
"github.com/photoprism/photoprism/pkg/header"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/ai/face"
@ -875,3 +877,19 @@ func TestFile_SetOrientation(t *testing.T) {
assert.Equal(t, "", m.FileOrientationSrc)
})
}
func TestFile_ContentType(t *testing.T) {
t.Run("Image", func(t *testing.T) {
m := FileFixtures.Get("exampleFileName.jpg")
assert.Equal(t, false, m.FileVideo)
assert.Equal(t, header.ContentTypeJPEG, m.ContentType())
})
t.Run("Video", func(t *testing.T) {
avc := FileFixtures.Get("Video.mp4")
assert.Equal(t, true, avc.FileVideo)
assert.Equal(t, header.ContentTypeAVC, avc.ContentType())
hevc := FileFixtures.Get("Photo21.mp4")
assert.Equal(t, true, hevc.FileVideo)
assert.Equal(t, header.ContentTypeHEVC, hevc.ContentType())
})
}

View file

@ -27,6 +27,7 @@ type GeoResult struct {
FileHeight int `json:"Height" select:"files.file_height"`
FileHash string `json:"Hash" select:"files.file_hash"`
FileCodec string `json:"-" select:"files.file_codec"`
FileMime string `json:"-" select:"files.file_mime"`
FileVideo bool `json:"-" select:"files.file_video"`
MediaType string `json:"-" select:"files.media_type"`
}

View file

@ -4,6 +4,8 @@ import (
"fmt"
"time"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/gosimple/slug"
"github.com/jinzhu/gorm"
"github.com/ulule/deepcopier"
@ -194,28 +196,28 @@ func (m *Photo) IsPlayable() bool {
}
// MediaInfo returns the media file hash and codec depending on the media type.
func (m *Photo) MediaInfo() (mediaHash, mediaCodec string) {
func (m *Photo) MediaInfo() (mediaHash, mediaCodec, mediaMime string) {
if m.PhotoType == entity.MediaVideo || m.PhotoType == entity.MediaLive {
for _, f := range m.Files {
if f.FileVideo && f.FileHash != "" {
return f.FileHash, f.FileCodec
return f.FileHash, f.FileCodec, clean.ContentType(f.FileMime)
}
}
} else if m.PhotoType == entity.MediaVector {
for _, f := range m.Files {
if f.MediaType == entity.MediaVector && f.FileHash != "" {
return f.FileHash, f.FileCodec
return f.FileHash, f.FileCodec, clean.ContentType(f.FileMime)
}
}
} else if m.PhotoType == entity.MediaDocument {
for _, f := range m.Files {
if f.MediaType == entity.MediaDocument && f.FileHash != "" {
return f.FileHash, f.FileCodec
return f.FileHash, f.FileCodec, clean.ContentType(f.FileMime)
}
}
}
return m.FileHash, ""
return m.FileHash, "", m.FileMime
}
// ShareBase returns a meaningful file name for sharing.

View file

@ -4,6 +4,8 @@ import (
"testing"
"time"
"github.com/photoprism/photoprism/pkg/header"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/media/video"
@ -180,7 +182,8 @@ func TestPhoto_MediaInfo(t *testing.T) {
{
FileVideo: true,
MediaType: media.Video.String(),
FileCodec: video.CodecAVC.String(),
FileMime: header.ContentTypeAVC,
FileCodec: video.CodecAVC,
FileHash: "53c89dcfa006c9e592dd9e6db4b31cd57be64b81",
},
},
@ -188,9 +191,10 @@ func TestPhoto_MediaInfo(t *testing.T) {
assert.True(t, r.IsPlayable())
mediaHash, mediaCodec := r.MediaInfo()
mediaHash, mediaCodec, mediaMime := r.MediaInfo()
assert.Equal(t, "53c89dcfa006c9e592dd9e6db4b31cd57be64b81", mediaHash)
assert.Equal(t, video.CodecAVC.String(), mediaCodec)
assert.Equal(t, video.CodecAVC, mediaCodec)
assert.Equal(t, header.ContentTypeAVC, mediaMime)
})
t.Run("VideoCodecHVC", func(t *testing.T) {
r := Photo{
@ -207,24 +211,28 @@ func TestPhoto_MediaInfo(t *testing.T) {
{
FileVideo: false,
MediaType: media.Image.String(),
FileMime: header.ContentTypeJPEG,
FileCodec: "jpeg",
},
{
FileVideo: true,
MediaType: media.Video.String(),
FileMime: header.ContentTypeAVC,
FileCodec: "xyz",
FileHash: "",
},
{
FileVideo: true,
MediaType: media.Video.String(),
FileCodec: video.CodecHVC.String(),
FileCodec: video.CodecHEVC,
FileMime: header.ContentTypeHEVC,
FileHash: "057258b0c88c2e017ec171cc8799a5df7badbadf",
},
{
FileVideo: true,
MediaType: media.Video.String(),
FileCodec: video.CodecAVC.String(),
FileCodec: video.CodecAVC,
FileMime: header.ContentTypeAVC,
FileHash: "ddb3f44eb500d7669cbe0a95e66d5a63f642487d",
},
},
@ -232,9 +240,10 @@ func TestPhoto_MediaInfo(t *testing.T) {
assert.True(t, r.IsPlayable())
mediaHash, mediaCodec := r.MediaInfo()
mediaHash, mediaCodec, mediaMime := r.MediaInfo()
assert.Equal(t, "057258b0c88c2e017ec171cc8799a5df7badbadf", mediaHash)
assert.Equal(t, video.CodecHVC.String(), mediaCodec)
assert.Equal(t, video.CodecHEVC, mediaCodec)
assert.Equal(t, header.ContentTypeHEVC, mediaMime)
})
t.Run("NoVideoHash", func(t *testing.T) {
r := Photo{
@ -251,6 +260,7 @@ func TestPhoto_MediaInfo(t *testing.T) {
{
FileVideo: true,
MediaType: media.Video.String(),
FileMime: header.ContentTypeAVC,
FileHash: "",
},
},
@ -258,9 +268,10 @@ func TestPhoto_MediaInfo(t *testing.T) {
assert.True(t, r.IsPlayable())
mediaHash, mediaCodec := r.MediaInfo()
mediaHash, mediaCodec, mediaMime := r.MediaInfo()
assert.Equal(t, "e22a06fb5b63dae7f3d08ab95fb958935b744e51", mediaHash)
assert.Equal(t, "", mediaCodec)
assert.Equal(t, "", mediaMime)
})
}

View file

@ -25,7 +25,7 @@ func UserPhotosViewerResults(frm form.SearchPhotos, sess *entity.Session, conten
// ViewerResult returns a new photo viewer result.
func (m *Photo) ViewerResult(contentUri, apiUri, previewToken, downloadToken string) viewer.Result {
mediaHash, mediaCodec := m.MediaInfo()
mediaHash, mediaCodec, mediaMime := m.MediaInfo()
return viewer.Result{
UID: m.PhotoUID,
Type: m.PhotoType,
@ -41,6 +41,7 @@ func (m *Photo) ViewerResult(contentUri, apiUri, previewToken, downloadToken str
Height: m.FileHeight,
Hash: mediaHash,
Codec: mediaCodec,
Mime: mediaMime,
Thumbs: thumb.ViewerThumbs(m.FileWidth, m.FileHeight, m.FileHash, contentUri, previewToken),
DownloadUrl: viewer.DownloadUrl(m.FileHash, apiUri, downloadToken),
}
@ -79,6 +80,7 @@ func (m GeoResult) ViewerResult(contentUri, apiUri, previewToken, downloadToken
Height: m.FileHeight,
Hash: m.FileHash,
Codec: m.FileCodec,
Mime: m.FileMime,
Thumbs: thumb.ViewerThumbs(m.FileWidth, m.FileHeight, m.FileHash, contentUri, previewToken),
DownloadUrl: viewer.DownloadUrl(m.FileHash, apiUri, downloadToken),
}

View file

@ -22,6 +22,7 @@ type Result struct {
Height int `json:"Height"`
Hash string `json:"Hash"`
Codec string `json:"Codec,omitempty"`
Mime string `json:"Mime,omitempty"`
Thumbs *thumb.Viewer `json:"Thumbs"`
DownloadUrl string `json:"DownloadUrl,omitempty"`
}

View file

@ -112,7 +112,7 @@ func TestJSON(t *testing.T) {
}
assert.Equal(t, "earth.ogv", data.FileName)
assert.Equal(t, string(video.CodecOGV), data.Codec)
assert.Equal(t, video.CodecOGV, data.Codec)
assert.Equal(t, "0s", data.Duration.String())
assert.Equal(t, 1280, data.Width)
assert.Equal(t, 720, data.Height)
@ -128,7 +128,7 @@ func TestJSON(t *testing.T) {
}
assert.Equal(t, "earth.vp8.webm", data.FileName)
assert.Equal(t, string(video.CodecVP8), data.Codec)
assert.Equal(t, video.CodecVP8, data.Codec)
assert.Equal(t, "30s", data.Duration.String())
assert.Equal(t, 1920, data.Width)
assert.Equal(t, 1080, data.Height)
@ -144,7 +144,7 @@ func TestJSON(t *testing.T) {
}
assert.Equal(t, "earth-animation.ogv.720p.vp9.webm", data.FileName)
assert.Equal(t, string(video.CodecVP9), data.Codec)
assert.Equal(t, video.CodecVP9, data.Codec)
assert.Equal(t, "8.03s", data.Duration.String())
assert.Equal(t, 1280, data.Width)
assert.Equal(t, 720, data.Height)
@ -705,7 +705,7 @@ func TestJSON(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, string(video.CodecAVC), data.Codec)
assert.Equal(t, video.CodecAVC, data.Codec)
assert.Equal(t, "10.67s", data.Duration.String())
assert.Equal(t, "2015-12-06 18:22:29 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2015-12-06 15:22:29 +0000 UTC", data.TakenAt.String())
@ -730,7 +730,7 @@ func TestJSON(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, string(video.CodecHVC), data.Codec)
assert.Equal(t, video.CodecHEVC, data.Codec)
assert.Equal(t, "6.83s", data.Duration.String())
assert.Equal(t, "2020-12-22 02:45:43 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2020-12-22 01:45:43 +0000 UTC", data.TakenAt.String())
@ -754,7 +754,7 @@ func TestJSON(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, string(video.CodecHVC), data.Codec)
assert.Equal(t, video.CodecHEVC, data.Codec)
assert.Equal(t, "2.15s", data.Duration.String())
assert.Equal(t, "2019-12-12 20:47:21 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2019-12-13 01:47:21 +0000 UTC", data.TakenAt.String())
@ -792,7 +792,7 @@ func TestJSON(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, string(video.CodecAVC), data.Codec)
assert.Equal(t, video.CodecAVC, data.Codec)
assert.Equal(t, "6.09s", data.Duration.String())
assert.Equal(t, "2022-06-25 06:50:58 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2022-06-25 04:50:58 +0000 UTC", data.TakenAt.String())
@ -969,7 +969,7 @@ func TestJSON(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, string(video.CodecAVC), data.Codec)
assert.Equal(t, video.CodecAVC, data.Codec)
assert.Equal(t, "1.03s", data.Duration.String())
assert.Equal(t, "2012-07-11 07:16:01 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2012-07-11 05:16:01 +0000 UTC", data.TakenAt.String())
@ -986,7 +986,7 @@ func TestJSON(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, string(video.CodecAVC), data.Codec)
assert.Equal(t, video.CodecAVC, data.Codec)
assert.Equal(t, "1.03s", data.Duration.String())
assert.Equal(t, "2012-07-11 07:16:01 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2012-07-11 05:16:01 +0000 UTC", data.TakenAt.String())
@ -1003,7 +1003,7 @@ func TestJSON(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, string(video.CodecAVC), data.Codec)
assert.Equal(t, video.CodecAVC, data.Codec)
assert.Equal(t, "1.03s", data.Duration.String())
assert.Equal(t, "2012-07-11 07:16:01 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2012-07-11 05:16:01 +0000 UTC", data.TakenAt.String())

View file

@ -5,9 +5,9 @@ import (
)
const CodecUnknown = ""
const CodecAv1 = string(video.CodecAV1)
const CodecAvc1 = string(video.CodecAVC)
const CodecHvc1 = string(video.CodecHVC)
const CodecAv1 = video.CodecAV1
const CodecAvc1 = video.CodecAVC
const CodecHvc1 = video.CodecHEVC
const CodecJpeg = "jpeg"
const CodecHeic = "heic"
const CodecXMP = "xmp"

View file

@ -811,7 +811,7 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
// Update file properties.
file.FileSidecar = m.IsSidecar()
file.FileType = m.FileType().String()
file.FileMime = m.MimeType()
file.FileMime = m.ContentType()
file.SetOrientation(m.Orientation(), entity.SrcMeta)
file.ModTime = modTime.UTC().Truncate(time.Second).Unix()

View file

@ -45,6 +45,7 @@ type MediaFile struct {
fileSize int64
fileType fs.Type
mimeType string
contentType string
takenAt time.Time
takenAtSrc string
hash string
@ -508,7 +509,11 @@ func (m *MediaFile) Root() string {
return m.fileRoot
}
// MimeType returns the mime type.
// MimeType returns the mimetype of this file, or an empty string if it could not be determined.
//
// The IANA and IETF use the term "media type", and consider the term "MIME type" to be obsolete,
// since media types have become used in contexts unrelated to email, such as HTTP:
// https://en.wikipedia.org/wiki/Media_type#Structure
func (m *MediaFile) MimeType() string {
if m.mimeType != "" {
return m.mimeType
@ -527,6 +532,39 @@ func (m *MediaFile) MimeType() string {
return m.mimeType
}
// BaseType returns the basic mime type, without any optional parameters.
func (m *MediaFile) BaseType() string {
return fs.BaseType(m.MimeType())
}
// HasMimeType tests if the specified mime type is the same, except for any optional parameters.
func (m *MediaFile) HasMimeType(mimeType string) bool {
return fs.IsType(m.MimeType(), mimeType)
}
// ContentType returns the media content type.
func (m *MediaFile) ContentType() string {
if m.contentType != "" {
return m.contentType
}
m.contentType = m.MimeType()
// Append codecs if media file is a video.
if m.IsVideo() {
if !strings.Contains(m.contentType, ";") && m.MetaData().Codec != "" {
m.contentType = fmt.Sprintf("%s; codecs=\"%s\"", m.contentType, clean.Codec(m.MetaData().Codec))
}
}
// Normalize media content type.
m.contentType = clean.ContentType(m.contentType)
log.Debugf("media: %s has content type %s", clean.Log(m.RootRelName()), clean.LogQuote(m.contentType))
return m.contentType
}
// openFile opens the file and returns the descriptor.
func (m *MediaFile) openFile() (handle *os.File, err error) {
fileName := m.FileName()
@ -688,7 +726,7 @@ func (m *MediaFile) IsJpeg() bool {
}
// Check the mime type after other tests have passed to improve performance.
return m.MimeType() == fs.MimeTypeJPEG
return m.HasMimeType(fs.MimeTypeJPEG)
}
// IsJpegXL checks if the file is a JPEG XL image with a supported file type extension.
@ -698,7 +736,7 @@ func (m *MediaFile) IsJpegXL() bool {
}
// Check the mime type after other tests have passed to improve performance.
return m.MimeType() == fs.MimeTypeJPEGXL
return m.HasMimeType(fs.MimeTypeJPEGXL)
}
// IsPNG checks if the file is a PNG image with a supported file type extension.
@ -710,8 +748,7 @@ func (m *MediaFile) IsPNG() bool {
}
// Check the mime type after other tests have passed to improve performance.
mimeType := m.MimeType()
return mimeType == fs.MimeTypePNG || mimeType == fs.MimeTypeAPNG
return m.HasMimeType(fs.MimeTypePNG) || m.HasMimeType(fs.MimeTypeAPNG)
}
// IsGIF checks if the file is a GIF image with a supported file type extension.
@ -721,7 +758,7 @@ func (m *MediaFile) IsGIF() bool {
}
// Check the mime type after other tests have passed to improve performance.
return m.MimeType() == fs.MimeTypeGIF
return m.HasMimeType(fs.MimeTypeGIF)
}
// IsTIFF checks if the file is a TIFF image with a supported file type extension.
@ -731,7 +768,7 @@ func (m *MediaFile) IsTIFF() bool {
}
// Check the mime type after other tests have passed to improve performance.
return m.MimeType() == fs.MimeTypeTIFF
return m.HasMimeType(fs.MimeTypeTIFF)
}
// IsDNG checks if the file is a Adobe Digital Negative (DNG) image with a supported file type extension.
@ -740,7 +777,7 @@ func (m *MediaFile) IsDNG() bool {
return false
}
return m.MimeType() == fs.MimeTypeDNG
return m.HasMimeType(fs.MimeTypeDNG)
}
// IsHEIF checks if the file is a High Efficiency Image File Format (HEIF) container with a supported file type extension.
@ -755,8 +792,7 @@ func (m *MediaFile) IsHEIC() bool {
}
// Check the mime type after other tests have passed to improve performance.
mimeType := m.MimeType()
return mimeType == fs.MimeTypeHEIC || mimeType == fs.MimeTypeHEICS
return m.HasMimeType(fs.MimeTypeHEIC) || m.HasMimeType(fs.MimeTypeHEICS)
}
// IsHEICS checks if the file is a HEIC image sequence with a supported file type extension.
@ -770,7 +806,7 @@ func (m *MediaFile) IsAVIF() bool {
return false
}
return m.MimeType() == fs.MimeTypeAVIF
return m.HasMimeType(fs.MimeTypeAVIF)
}
// IsAVIFS checks if the file is an AVIF image sequence with a supported file type extension.
@ -785,7 +821,7 @@ func (m *MediaFile) IsBMP() bool {
}
// Check the mime type after other tests have passed to improve performance.
return m.MimeType() == fs.MimeTypeBMP
return m.HasMimeType(fs.MimeTypeBMP)
}
// IsWebP checks if the file is a WebP image file with a supported file type extension.
@ -794,7 +830,7 @@ func (m *MediaFile) IsWebP() bool {
return false
}
return m.MimeType() == fs.MimeTypeWebP
return m.HasMimeType(fs.MimeTypeWebP)
}
// Duration returns the duration is the media content is playable.
@ -853,7 +889,7 @@ func (m *MediaFile) CheckType() error {
// Detect media type (formerly known as a MIME type),
// see https://en.wikipedia.org/wiki/Media_type
mimeType := m.MimeType()
mimeType := m.BaseType()
// Perform mime type checks for selected file types.
var valid bool

View file

@ -108,13 +108,13 @@ func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err e
}
if len(result.Files) == 0 || result.Main == nil {
t := m.MimeType()
mediaType := m.BaseType()
if t == "" {
t = "unknown type"
if mediaType == "" {
mediaType = "unknown type"
}
return result, fmt.Errorf("%s is unsupported (%s)", clean.Log(m.BaseName()), t)
return result, fmt.Errorf("%s is unsupported (%s)", clean.Log(m.BaseName()), mediaType)
}
// Add hidden preview image if needed.

View file

@ -699,10 +699,15 @@ func TestMediaFile_MimeType(t *testing.T) {
})
t.Run("iphone_7.xmp", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.xmp")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "text/plain", mediaFile.MimeType())
assert.Equal(t, fs.MimeTypeText, mediaFile.BaseType())
assert.Equal(t, "text/plain", mediaFile.BaseType())
assert.Equal(t, "text/plain; charset=utf-8", mediaFile.MimeType())
assert.Equal(t, true, mediaFile.HasMimeType("text/plain"))
})
t.Run("iphone_7.json", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.json")
@ -732,7 +737,11 @@ func TestMediaFile_MimeType(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, fs.MimeTypeXML, mediaFile.MimeType())
assert.Equal(t, fs.MimeTypeXML, mediaFile.BaseType())
assert.Equal(t, "text/xml", mediaFile.BaseType())
assert.Equal(t, "text/xml; charset=utf-8", mediaFile.MimeType())
assert.Equal(t, true, mediaFile.HasMimeType("text/xml"))
})
t.Run("earth.mov", func(t *testing.T) {
if f, err := NewMediaFile(filepath.Join(c.ExamplesPath(), "earth.mov")); err != nil {
@ -2023,10 +2032,11 @@ func TestMediaFile_ExceedsResolution(t *testing.T) {
}
func TestMediaFile_AspectRatio(t *testing.T) {
t.Run("iphone_7.heic", func(t *testing.T) {
conf := config.TestConfig()
c := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/iphone_7.heic")
t.Run("iphone_7.heic", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.heic")
if err != nil {
t.Fatal(err)
@ -2037,9 +2047,7 @@ func TestMediaFile_AspectRatio(t *testing.T) {
assert.False(t, mediaFile.Square())
})
t.Run("fern_green.jpg", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/fern_green.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/fern_green.jpg")
if err != nil {
t.Fatal(err)
@ -2050,9 +2058,7 @@ func TestMediaFile_AspectRatio(t *testing.T) {
assert.True(t, mediaFile.Square())
})
t.Run("elephants.jpg", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/elephants.jpg")
if err != nil {
t.Fatal(err)
@ -2116,6 +2122,8 @@ func TestMediaFile_FileType(t *testing.T) {
assert.False(t, m.IsPNG())
assert.Equal(t, "png", string(m.FileType()))
assert.Equal(t, "image/jpeg", m.MimeType())
assert.Equal(t, "image/jpeg", m.BaseType())
assert.Equal(t, true, m.HasMimeType("image/jpeg"))
assert.Equal(t, fs.ImagePNG, m.FileType())
assert.Equal(t, ".png", m.Extension())
})
@ -2131,6 +2139,7 @@ func TestMediaFile_FileType(t *testing.T) {
assert.False(t, m.IsPNG())
assert.Equal(t, "thm", string(m.FileType()))
assert.Equal(t, "image/jpeg", m.MimeType())
assert.Equal(t, true, m.HasMimeType("image/jpeg"))
assert.Equal(t, fs.ImageThumb, m.FileType())
assert.Equal(t, ".thm", m.Extension())
})
@ -2138,9 +2147,9 @@ func TestMediaFile_FileType(t *testing.T) {
func TestMediaFile_Stat(t *testing.T) {
t.Run("iphone_7.heic", func(t *testing.T) {
conf := config.TestConfig()
c := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/iphone_7.heic")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.heic")
if err != nil {
t.Fatal(err)
@ -2159,9 +2168,9 @@ func TestMediaFile_Stat(t *testing.T) {
func TestMediaFile_FileSize(t *testing.T) {
t.Run("iphone_7.heic", func(t *testing.T) {
conf := config.TestConfig()
c := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/iphone_7.heic")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.heic")
if err != nil {
t.Fatal(err)
@ -2174,9 +2183,9 @@ func TestMediaFile_FileSize(t *testing.T) {
func TestMediaFile_JsonName(t *testing.T) {
t.Run("blue-go-video.mp4", func(t *testing.T) {
conf := config.TestConfig()
c := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/blue-go-video.mp4")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/blue-go-video.mp4")
if err != nil {
t.Fatal(err)
@ -2188,10 +2197,10 @@ func TestMediaFile_JsonName(t *testing.T) {
}
func TestMediaFile_PathNameInfo(t *testing.T) {
t.Run("blue-go-video.mp4", func(t *testing.T) {
conf := config.TestConfig()
c := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/blue-go-video.mp4")
t.Run("blue-go-video.mp4", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/blue-go-video.mp4")
if err != nil {
t.Fatal(err)
@ -2204,11 +2213,8 @@ func TestMediaFile_PathNameInfo(t *testing.T) {
assert.Equal(t, "blue-go-video.mp4", name)
})
t.Run("beach_sand sidecar", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/beach_sand.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/beach_sand.jpg")
if err != nil {
t.Fatal(err)
@ -2224,13 +2230,11 @@ func TestMediaFile_PathNameInfo(t *testing.T) {
assert.Equal(t, ".photoprism/beach_sand.jpg", name)
mediaFile.SetFileName(initialName)
})
t.Run("beach_sand import", func(t *testing.T) {
conf := config.TestConfig()
t.Log(Config().SidecarPath())
t.Log(Config().ImportPath())
mediaFile, err := NewMediaFile(filepath.Join(conf.ExamplesPath(), "beach_sand.jpg"))
mediaFile, err := NewMediaFile(filepath.Join(c.ExamplesPath(), "beach_sand.jpg"))
if err != nil {
t.Fatal(err)
@ -2238,7 +2242,7 @@ func TestMediaFile_PathNameInfo(t *testing.T) {
initialName := mediaFile.FileName()
t.Log(initialName)
mediaFile.SetFileName(filepath.Join(conf.ImportPath(), "beach_sand.jpg"))
mediaFile.SetFileName(filepath.Join(c.ImportPath(), "beach_sand.jpg"))
root, base, path, name := mediaFile.PathNameInfo(true)
assert.Equal(t, "import", root)
@ -2249,9 +2253,7 @@ func TestMediaFile_PathNameInfo(t *testing.T) {
})
t.Run("beach_sand unknown root", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/beach_sand.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/beach_sand.jpg")
if err != nil {
t.Fatal(err)
@ -2270,10 +2272,10 @@ func TestMediaFile_PathNameInfo(t *testing.T) {
}
func TestMediaFile_SubDirectory(t *testing.T) {
t.Run("blue-go-video.mp4", func(t *testing.T) {
conf := config.TestConfig()
c := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/blue-go-video.mp4")
t.Run("blue-go-video.mp4", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/blue-go-video.mp4")
if err != nil {
t.Fatal(err)
@ -2285,16 +2287,16 @@ func TestMediaFile_SubDirectory(t *testing.T) {
}
func TestMediaFile_HasSameName(t *testing.T) {
t.Run("false", func(t *testing.T) {
conf := config.TestConfig()
c := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/blue-go-video.mp4")
t.Run("false", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/blue-go-video.mp4")
if err != nil {
t.Fatal(err)
}
mediaFile2, err := NewMediaFile(conf.ExamplesPath() + "/beach_sand.jpg")
mediaFile2, err := NewMediaFile(c.ExamplesPath() + "/beach_sand.jpg")
if err != nil {
t.Fatal(err)

39
pkg/clean/content_type.go Normal file
View file

@ -0,0 +1,39 @@
package clean
import (
"github.com/photoprism/photoprism/pkg/header"
)
// ContentType normalizes media content type strings, see https://en.wikipedia.org/wiki/Media_type.
func ContentType(s string) string {
if s == "" {
return header.ContentTypeBinary
}
s = Type(s)
switch s {
case "":
return header.ContentTypeBinary
case "text/json", "application/json":
return header.ContentTypeJsonUtf8
case "text/htm", "text/html":
return header.ContentTypeHtml
case "text/plain":
return header.ContentTypeText
case "text/pdf", "text/x-pdf", "application/x-pdf", "application/acrobat":
return header.ContentTypePDF
case "image/svg":
return header.ContentTypeSVG
case "image/jpe", "image/jpg":
return header.ContentTypeJPEG
case "video/mp4; codecs=\"avc\"":
return header.ContentTypeAVC
case "video/mp4; codecs=\"hvc1\"", "video/mp4; codecs=\"hvc\"", "video/mp4; codecs=\"hevc\"":
return header.ContentTypeHEVC
case "video/webm; codecs=\"av01\"":
return header.ContentTypeAV1
}
return s
}

View file

@ -31,8 +31,10 @@ func Log(s string) string {
case ' ':
spaces = true
return r
case '`', '"':
case '`':
return '\''
case '"':
return '"'
case '\\', '$', '<', '>', '{', '}':
return '?'
default:

View file

@ -25,7 +25,7 @@ func TestLog(t *testing.T) {
assert.Equal(t, "?", Log("User-Agent: {jndi:ldap://<host>:<port>/<path>}"))
})
t.Run("SpecialChars", func(t *testing.T) {
assert.Equal(t, "' The ?quick? ''brown 'fox. '", Log(" The <quick>\n\r ''brown \"fox. \t "))
assert.Equal(t, "' The ?quick? ''brown \"fox. '", Log(" The <quick>\n\r ''brown \"fox. \t "))
})
t.Run("LoremIpsum", func(t *testing.T) {
assert.Equal(t, "'It is a long established fact that a reader will be distracted by the readable "+

View file

@ -29,11 +29,16 @@ const (
MimeTypeAI = "application/vnd.adobe.illustrator"
MimeTypePS = "application/postscript"
MimeTypeEPS = "image/eps"
MimeTypeText = "text/plain"
MimeTypeXML = "text/xml"
MimeTypeJSON = "application/json"
)
// MimeType returns the mime type of a file, or an empty string if it could not be detected.
// MimeType returns the mimetype of a file, or an empty string if it could not be determined.
//
// The IANA and IETF use the term "media type", and consider the term "MIME type" to be obsolete,
// since media types have become used in contexts unrelated to email, such as HTTP:
// https://en.wikipedia.org/wiki/Media_type#Structure
func MimeType(filename string) (mimeType string) {
if filename == "" {
return MimeTypeUnknown
@ -69,7 +74,7 @@ func MimeType(filename string) (mimeType string) {
detectedType, err := mimetype.DetectFile(filename)
if detectedType != nil && err == nil {
mimeType, _, _ = strings.Cut(detectedType.String(), ";")
mimeType = detectedType.String()
}
// Treat "application/octet-stream" as unknown.
@ -100,3 +105,23 @@ func MimeType(filename string) (mimeType string) {
return mimeType
}
// BaseType returns the media type string without any optional parameters.
func BaseType(mimeType string) string {
if mimeType == "" {
return ""
}
mimeType, _, _ = strings.Cut(mimeType, ";")
return strings.ToLower(mimeType)
}
// IsType tests if the specified mime types are matching, except for any optional parameters.
func IsType(mime1, mime2 string) bool {
if mime1 == mime2 {
return true
}
return BaseType(mime1) == BaseType(mime2)
}

View file

@ -77,3 +77,93 @@ func TestMimeType(t *testing.T) {
assert.Equal(t, "image/eps", mimeType)
})
}
func TestBaseType(t *testing.T) {
t.Run("MP4", func(t *testing.T) {
filename := Abs("./testdata/test.mp4")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "video/mp4", mimeType)
})
t.Run("MOV", func(t *testing.T) {
filename := Abs("./testdata/test.mov")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "video/quicktime", mimeType)
})
t.Run("JPEG", func(t *testing.T) {
filename := Abs("./testdata/test.jpg")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "image/jpeg", mimeType)
})
t.Run("InvalidFilename", func(t *testing.T) {
filename := Abs("./testdata/xxx.jpg")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "", mimeType)
})
t.Run("EmptyFilename", func(t *testing.T) {
mimeType := BaseType("")
assert.Equal(t, "", mimeType)
})
t.Run("AVIF", func(t *testing.T) {
filename := Abs("./testdata/test.avif")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "image/avif", mimeType)
})
t.Run("AVIFS", func(t *testing.T) {
filename := Abs("./testdata/test.avifs")
mimeType := MimeType(filename)
assert.Equal(t, "image/avif-sequence", mimeType)
})
t.Run("HEIC", func(t *testing.T) {
filename := Abs("./testdata/test.heic")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "image/heic", mimeType)
})
t.Run("HEICS", func(t *testing.T) {
filename := Abs("./testdata/test.heics")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "image/heic-sequence", mimeType)
})
t.Run("DNG", func(t *testing.T) {
filename := Abs("./testdata/test.dng")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "image/dng", mimeType)
})
t.Run("SVG", func(t *testing.T) {
filename := Abs("./testdata/test.svg")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "image/svg+xml", mimeType)
})
t.Run("AI", func(t *testing.T) {
filename := Abs("./testdata/test.ai")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "application/vnd.adobe.illustrator", mimeType)
})
t.Run("PS", func(t *testing.T) {
filename := Abs("./testdata/test.ps")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "application/postscript", mimeType)
})
t.Run("EPS", func(t *testing.T) {
filename := Abs("./testdata/test.eps")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "image/eps", mimeType)
})
}
func TestIsType(t *testing.T) {
t.Run("True", func(t *testing.T) {
assert.True(t, IsType("", MimeTypeUnknown))
assert.True(t, IsType("video/jpg", "video/jpg"))
assert.True(t, IsType("video/jpeg", "video/jpeg"))
assert.True(t, IsType("video/mp4", "video/mp4"))
assert.True(t, IsType("video/mp4", MimeTypeMP4))
assert.True(t, IsType("video/mp4", "video/MP4"))
assert.True(t, IsType("video/mp4", "video/MP4; codecs=\"avc1\""))
})
t.Run("False", func(t *testing.T) {
assert.False(t, IsType("", MimeTypeMP4))
assert.False(t, IsType("video/jpeg", "video/jpg"))
assert.False(t, IsType("video/mp4", MimeTypeUnknown))
assert.False(t, IsType(MimeTypeMP4, MimeTypeJPEG))
})
}

View file

@ -17,14 +17,22 @@ const (
// Standard ContentType header values.
const (
ContentTypeBinary = "application/octet-stream"
ContentTypeForm = "application/x-www-form-urlencoded"
ContentTypeMultipart = "multipart/form-data"
ContentTypeJson = "application/json"
ContentTypeJsonUtf8 = "application/json; charset=utf-8"
ContentTypeHtml = "text/html; charset=utf-8"
ContentTypeText = "text/plain; charset=utf-8"
ContentTypePDF = "application/pdf"
ContentTypePNG = "image/png"
ContentTypeJPEG = "image/jpeg"
ContentTypeSVG = "image/svg+xml"
ContentTypeAVC = "video/mp4; codecs=\"avc1\""
ContentTypeHEVC = "video/mp4; codecs=\"hvc1.1.6.L93.90\""
ContentTypeOGG = "video/ogg"
ContentTypeWebM = "video/webm"
ContentTypeVP8 = "video/webm; codecs=\"vp8\""
ContentTypeVP9 = "video/webm; codecs=\"vp9\""
ContentTypeAV1 = "video/webm; codecs=\"av01.0.08M.08\""
)

View file

@ -1,18 +1,13 @@
package video
type Codec string
// String returns the codec name as string.
func (c Codec) String() string {
return string(c)
}
// Check browser support: https://cconcolato.github.io/media-mime-support/
type Codec = string
// Video codecs supported by web browsers:
// https://cconcolato.github.io/media-mime-support/
const (
CodecUnknown Codec = ""
CodecAVC Codec = "avc1"
CodecHVC Codec = "hvc1"
CodecHEVC Codec = "hvc1"
CodecVVC Codec = "vvc"
CodecEVC Codec = "evc"
CodecAV1 Codec = "av01"
@ -34,11 +29,14 @@ var Codecs = StandardCodecs{
"iso/avc": CodecAVC,
"v_mpeg4/avc": CodecAVC,
"v_mpeg4/iso/avc": CodecAVC,
"hevc": CodecHVC,
"hvc": CodecHVC,
"hvc1": CodecHVC,
"v_hvc": CodecHVC,
"v_hvc1": CodecHVC,
"hevc": CodecHEVC,
"hevC": CodecHEVC,
"hvc": CodecHEVC,
"hvc1": CodecHEVC,
"v_hvc": CodecHEVC,
"v_hvc1": CodecHEVC,
"hev": CodecHEVC,
"hev1": CodecHEVC,
"evc": CodecEVC,
"evc1": CodecEVC,
"evcC": CodecEVC,

View file

@ -13,7 +13,7 @@ func TestContentType(t *testing.T) {
assert.Equal(t, fs.MimeTypeMOV, ContentType(fs.MimeTypeMOV, ""))
})
t.Run("QuickTime_HVC", func(t *testing.T) {
assert.Equal(t, `video/quicktime; codecs="hvc1"`, ContentType(fs.MimeTypeMOV, CodecHVC))
assert.Equal(t, `video/quicktime; codecs="hvc1"`, ContentType(fs.MimeTypeMOV, CodecHEVC))
})
t.Run("MP4", func(t *testing.T) {
assert.Equal(t, fs.MimeTypeMP4, ContentType(fs.MimeTypeMP4, ""))
@ -22,6 +22,6 @@ func TestContentType(t *testing.T) {
assert.Equal(t, ContentTypeAVC, ContentType(fs.MimeTypeMP4, CodecAVC))
})
t.Run("MP4_HVC", func(t *testing.T) {
assert.Equal(t, `video/mp4; codecs="hvc1"`, ContentType(fs.MimeTypeMP4, CodecHVC))
assert.Equal(t, `video/mp4; codecs="hvc1"`, ContentType(fs.MimeTypeMP4, CodecHEVC))
})
}

View file

@ -147,7 +147,7 @@ func Probe(file io.ReadSeeker) (info Info, err error) {
// Detect codec by searching for matching chunks.
if info.VideoCodec == "" {
if found, _ := ChunkHVC1.DataOffset(file); found > 0 {
info.VideoCodec = CodecHVC
info.VideoCodec = CodecHEVC
}
}

View file

@ -91,7 +91,7 @@ func TestProbeFile(t *testing.T) {
assert.Equal(t, int64(0), info.VideoOffset)
assert.Equal(t, int64(-1), info.ThumbOffset)
assert.Equal(t, media.Video, info.MediaType)
assert.Equal(t, CodecHVC, info.VideoCodec)
assert.Equal(t, CodecHEVC, info.VideoCodec)
assert.Equal(t, fs.MimeTypeMOV, info.VideoMimeType)
assert.Equal(t, ContentTypeMOV+`; codecs="hvc1"`, info.VideoContentType())
assert.Equal(t, "1.166666666s", info.Duration.String())

View file

@ -2,30 +2,34 @@ package video
// Types maps identifiers to standards.
var Types = Standards{
"": AVC,
"mp4": MP4,
"mpeg4": MP4,
"avc": AVC,
"avc1": AVC,
"hvc": HEVC,
"hvc1": HEVC,
"hevc": HEVC,
"hevC": HEVC,
"evc": EVC,
"evc1": EVC,
"evcC": EVC,
"vvc": VVC,
"vvc1": VVC,
"vvcC": VVC,
"vp8": VP8,
"vp80": VP8,
"vp9": VP9,
"vp90": VP9,
"av1": AV1,
"av01": AV1,
"ogg": OGV,
"ogv": OGV,
"webm": WebM,
"": AVC,
"mp4": MP4,
"mpeg4": MP4,
"avc": AVC,
"avc1": AVC,
"hevc": HEVC,
"hevC": HEVC,
"hvc": HEVC,
"hvc1": HEVC,
"v_hvc": HEVC,
"v_hvc1": HEVC,
"hev": HEVC,
"hev1": HEVC,
"evc": EVC,
"evc1": EVC,
"evcC": EVC,
"vvc": VVC,
"vvc1": VVC,
"vvcC": VVC,
"vp8": VP8,
"vp80": VP8,
"vp9": VP9,
"vp90": VP9,
"av1": AV1,
"av01": AV1,
"ogg": OGV,
"ogv": OGV,
"webm": WebM,
}
// Standards maps names to standardized formats.

View file

@ -39,7 +39,7 @@ var AVC = Type{
// HEVC aka High Efficiency Video Coding (H.265).
var HEVC = Type{
Codec: CodecHVC,
Codec: CodecHEVC,
FileType: fs.VideoHEVC,
WidthLimit: 0,
HeightLimit: 0,