UX: Refactor video formats and codecs in front and backend #1307 #3168

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-01-28 23:26:52 +01:00
parent e42049bc89
commit 6b3cb0eca8
26 changed files with 483 additions and 343 deletions

View File

@@ -52,7 +52,13 @@ module.exports = (config) => {
customLaunchers: {
LocalChrome: {
base: "ChromeHeadless",
flags: ["--disable-translate", "--disable-extensions", "--no-sandbox", "--disable-web-security", "--disable-dev-shm-usage"],
flags: [
"--disable-translate",
"--disable-extensions",
"--no-sandbox",
"--disable-web-security",
"--disable-dev-shm-usage",
],
},
},
@@ -71,20 +77,20 @@ module.exports = (config) => {
coverageIstanbulReporter: {
// reports can be any that are listed here: https://github.com/istanbuljs/istanbuljs/tree/aae256fb8b9a3d19414dcf069c592e88712c32c6/packages/istanbul-reports/lib
reports: ["lcov", "text-summary"],
"reports": ["lcov", "text-summary"],
// base output directory. If you include %browser% in the path it will be replaced with the karma browser name
dir: path.join(__dirname, "coverage"),
"dir": path.join(__dirname, "coverage"),
// Combines coverage information from multiple browsers into one report rather than outputting a report
// for each browser.
combineBrowserReports: true,
"combineBrowserReports": true,
// if using webpack and pre-loaders, work around webpack breaking the source path
fixWebpackSourcePaths: true,
"fixWebpackSourcePaths": true,
// Omit files with no statements, no functions and no branches from the report
skipFilesWithNoCoverage: true,
"skipFilesWithNoCoverage": true,
// Most reporters accept additional config options. You can pass these through the `report-config` option
"report-config": {
@@ -97,7 +103,7 @@ module.exports = (config) => {
// enforce percentage thresholds
// anything under these percentages will cause karma to fail with an exit code of 1 if not running in watch mode
thresholds: {
"thresholds": {
emitWarning: true, // set to `true` to not fail the test command when thresholds are not met
// thresholds for all files
global: {
@@ -121,7 +127,7 @@ module.exports = (config) => {
},
},
verbose: true, // output config used by istanbul for debugging
"verbose": true, // output config used by istanbul for debugging
},
webpack: {
@@ -130,7 +136,11 @@ module.exports = (config) => {
fallback: {
util: require.resolve("util"),
},
modules: [path.join(__dirname, "src"), path.join(__dirname, "node_modules"), path.join(__dirname, "tests/unit")],
modules: [
path.join(__dirname, "src"),
path.join(__dirname, "node_modules"),
path.join(__dirname, "tests/unit"),
],
preferRelative: true,
alias: {
vue$: "vue/dist/vue.runtime.esm-bundler.js",

View File

@@ -3693,9 +3693,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.10.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.10.tgz",
"integrity": "sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww==",
"version": "22.12.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.12.0.tgz",
"integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
@@ -6691,16 +6691,16 @@
}
},
"node_modules/engine.io": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.3.tgz",
"integrity": "sha512-2hkLItQMBkoYSagneiisupWGvsQlWXqzhSMvsjaM8GYbnfUsX7tzYQq9QARnate5LRedVTX+MbkSZAANAr3NtQ==",
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz",
"integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~1.0.2",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
@@ -6719,15 +6719,6 @@
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/engine.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
@@ -10532,9 +10523,9 @@
}
},
"node_modules/loupe": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz",
"integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz",
"integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==",
"license": "MIT"
},
"node_modules/lru-cache": {
@@ -10587,9 +10578,9 @@
}
},
"node_modules/maplibre-gl": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.0.1.tgz",
"integrity": "sha512-kNvod1Tq0BcZvn43UAciA3DrzaEGmowqMoI6nh3kUo9rf+7m89mFJI9dELxkWzJ/N9Pgnkp7xF1jzTP08PGpCw==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.1.0.tgz",
"integrity": "sha512-6lbf7qAnqAVm1T/vJBMmRtP+g8G/O/Z52IBtWX31SbFj7sEdlrk4YugxJen8IdV/pFjLFnDOw7HiHZl5nYdVjg==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
@@ -10599,8 +10590,8 @@
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^1.3.1",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/maplibre-gl-style-spec": "^23.0.0",
"@types/geojson": "^7946.0.15",
"@maplibre/maplibre-gl-style-spec": "^23.1.0",
"@types/geojson": "^7946.0.16",
"@types/geojson-vt": "3.2.5",
"@types/mapbox__point-geometry": "^0.1.4",
"@types/mapbox__vector-tile": "^1.3.4",
@@ -16363,9 +16354,9 @@
}
},
"node_modules/vuetify": {
"version": "3.7.7",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.7.7.tgz",
"integrity": "sha512-YqKnRo2+BZWQRzfxHVA/5reQo1eLvbS9Z6N+Lvaot/5lpdi7JWooMr/hWoCr7/QPBGRcXArHppqIB+hMfPlsXw==",
"version": "3.7.8",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.7.8.tgz",
"integrity": "sha512-67p7Ton7EikHKhzauAmpsV1HT/zA2DmG8hjcHmQrktGl1eBrRaQVE+idWLtbhO51jqpTsrKQK9+HuG0KRwIGYQ==",
"license": "MIT",
"engines": {
"node": "^12.20 || >=14.13"

View File

@@ -0,0 +1,52 @@
/*
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
import * as media from "common/media";
export const useVideo = !!document.createElement("video").canPlayType;
export const useAvc = useVideo // AVC
? !!document.createElement("video").canPlayType(media.ContentTypeAVC)
: false;
export const useHevc = useVideo // HEVC, Basic Support
? !!document.createElement("video").canPlayType(media.ContentTypeHEVC)
: false;
export const useVvc = useVideo // VVC, Basic Support
? !!document.createElement("video").canPlayType(media.ContentTypeVVC)
: false;
export const useOGV = useVideo // Ogg Theora
? !!document.createElement("video").canPlayType(media.ContentTypeOGV)
: false;
export const useWebM = useVideo // Google WebM
? !!document.createElement("video").canPlayType(media.ContentTypeWebM)
: false;
export const useVp8 = useVideo // Google WebM, VP8
? !!document.createElement("video").canPlayType(media.ContentTypeVP8)
: false;
export const useVp9 = useVideo // Google WebM, VP9
? !!document.createElement("video").canPlayType(media.ContentTypeVP9)
: false;
export const useAv1 = useVideo // AV1, Main Profile
? !!document.createElement("video").canPlayType(media.ContentTypeAV1)
: false;

View File

@@ -1,55 +0,0 @@
/*
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/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(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(ContentTypeOGG)
: false;
export const canUseVP8 = canUseVideo // Google WebM, VP8
? !!document.createElement("video").canPlayType(ContentTypeVP8)
: false;
export const canUseVP9 = canUseVideo // Google WebM, VP9
? !!document.createElement("video").canPlayType(ContentTypeVP9)
: false;
export const canUseAv1 = canUseVideo // AV1, Main Profile
? !!document.createElement("video").canPlayType(ContentTypeAV1)
: false;
export const canUseWebM = canUseVideo // Google WebM
? !!document.createElement("video").canPlayType(ContentTypeWebM)
: false;

View File

@@ -0,0 +1,43 @@
export const CodecAVC = "avc1";
export const CodecHEV1 = "hev1";
export const CodecHEVC = "hvc1";
export const CodecVVC = "vvc1";
export const CodecEVC = "evc1";
export const CodecOGV = "ogv";
export const CodecVP08 = "vp08";
export const CodecVP09 = "vp09";
export const CodecAV1 = "av01";
export const CodecAV1C = "av1c";
export const FormatMP4 = "mp4";
export const FormatAVC = "avc";
export const FormatHEVC = "hevc";
export const FormatVVC = "vvc";
export const FormatEVC = "evc";
export const FormatOGG = "ogg";
export const FormatWebM = "webm";
export const FormatVP8 = "vp8";
export const FormatVP9 = "vp9";
export const FormatAV1 = "av1";
export const FormatWebP = "webp";
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";
export const MediaLive = "live";
export const MediaVideo = "video";
export const MediaVector = "vector";
export const MediaSidecar = "sidecar";
export const ContentTypeAVC = 'video/mp4; codecs="avc1"';
export const ContentTypeHEVC = 'video/mp4; codecs="hev1.2.4.L120.B0"';
export const ContentTypeVVC = 'video/mp4; codecs="vvc1"';
export const ContentTypeOGV = "video/ogg";
export const ContentTypeWebM = "video/webm";
export const ContentTypeVP8 = 'video/webm; codecs="vp08.02.41.10"';
export const ContentTypeVP9 = 'video/webm; codecs="vp09.00.50.08"';
export const ContentTypeAV1 = 'video/webm; codecs="av01.2.10M.10"';

View File

@@ -23,41 +23,15 @@ Additional information can be found in our Developer Guide:
*/
import {
canUseAv1,
canUseHevc,
canUseOGV,
canUseVP8,
canUseVP9,
canUseWebM,
ContentTypeAVC,
ContentTypeHEVC,
ContentTypeOGG,
ContentTypeWebM,
ContentTypeVP8,
ContentTypeVP9,
ContentTypeAV1,
} from "./caniuse";
import * as media from "common/media";
import * as can from "common/can";
import { config } from "app/session";
import {
DATE_FULL,
CodecAv01,
CodecAv1C,
CodecHev1,
CodecHvc1,
CodecOGV,
CodecVP8,
CodecVP9,
FormatAV1,
FormatAVC,
FormatHEVC,
FormatWebM,
CodecOGG,
} from "model/photo";
import { DATE_FULL } from "model/photo";
import sanitizeHtml from "sanitize-html";
import { DateTime } from "luxon";
import { $gettext } from "common/gettext";
import Notify from "common/notify";
import { FormatOGG, FormatVP8, FormatWebP } from "common/media";
const Nanosecond = 1;
const Microsecond = 1000 * Nanosecond;
@@ -296,18 +270,22 @@ export default class Util {
return "GIF";
case "dng":
return "Adobe Digital Negative";
case "avc":
case "avc1":
case media.FormatAVC:
case media.CodecAVC:
return "Advanced Video Coding (AVC) / H.264";
case "avif":
return "AOMedia Video 1 (AV1)";
case "avifs":
return "AVIF Image Sequence";
case "hev":
case "hvc":
case "hevc":
case "hev1":
case "hvc1":
case media.CodecHEV1:
case media.CodecHEVC:
case media.FormatHEVC:
return "High Efficiency Video Coding (HEVC) / H.265";
case media.FormatEVC:
case media.CodecEVC:
return "Essential Video Coding (MPEG-5 Part 1)";
case "m4v":
return "Apple iTunes Multimedia Container";
case "mkv":
@@ -318,8 +296,14 @@ export default class Util {
return "Blu-ray MPEG-2 Transport Stream";
case "webp":
return "Google WebP";
case "webm":
case media.FormatWebM:
return "Google WebM";
case media.CodecVP08:
case media.FormatVP8:
return "Google VP8";
case media.CodecVP09:
case media.FormatVP9:
return "Google VP9";
case "flv":
return "Flash";
case "mpg":
@@ -372,20 +356,23 @@ export default class Util {
}
switch (codec) {
case "webp":
case "extended webp":
return "WebP";
case "webm":
return "WebM";
case "av1c":
case "av01":
case media.CodecAV1C:
case media.CodecAV1:
return "AV1";
case "avc1":
case media.CodecAVC:
return "AVC";
case "hvc":
case "hev1":
case "hvc1":
case media.CodecHEV1:
case media.CodecHEVC:
return "HEVC";
case media.FormatWebM:
return "WebM";
case media.CodecVP08:
case media.FormatVP8:
return "VP8";
case "extended webp":
case media.FormatWebP:
return "WebP";
default:
return codec.toUpperCase();
}
@@ -407,15 +394,16 @@ export default class Util {
case "avc1":
return "Advanced Video Coding (AVC) / H.264";
case "hvc":
case "hevc":
case "hev1":
case "hvc1":
case "hev":
case media.CodecHEV1:
case media.CodecHEVC:
case media.FormatHEVC:
return "High Efficiency Video Coding (HEVC) / H.265";
case "vvc":
case "vvc1":
case media.FormatVVC:
case media.CodecVVC:
return "Versatile Video Coding (VVC) / H.266";
case "evc":
case "evc1":
case media.FormatEVC:
case media.CodecEVC:
return "Essential Video Coding (MPEG-5 Part 1)";
case "av1c":
case "av01":
@@ -562,23 +550,46 @@ export default class Util {
}
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 || mime === ContentTypeVP8)) {
return CodecVP8;
} else if (canUseVP9 && (codec === CodecVP9 || mime === ContentTypeVP9)) {
return CodecVP9;
} else if (canUseAv1 && (codec === CodecAv01 || codec === CodecAv1C || mime === ContentTypeAV1)) {
return FormatAV1;
} else if (canUseWebM && (codec === FormatWebM || mime === ContentTypeWebM)) {
return FormatWebM;
if ((!codec && !mime) || mime?.startsWith('video/mp4; codecs="avc')) {
return media.FormatAVC;
} else if (
can.useHevc &&
(codec === media.CodecHEVC ||
codec === media.CodecHEV1 ||
mime?.startsWith('video/mp4; codecs="hev') ||
mime?.startsWith('video/mp4; codecs="hvc'))
) {
return media.FormatHEVC;
} else if (
can.useVvc &&
(codec === media.CodecVVC || codec === media.FormatVVC || mime?.startsWith('video/mp4; codecs="vvc'))
) {
return media.FormatVVC;
} else if (can.useOGV && (codec === media.CodecOGV || codec === media.FormatOGG || mime === media.ContentTypeOGV)) {
return media.CodecOGV;
} else if (
can.useVp8 &&
(codec === media.CodecVP08 || codec === media.FormatVP8 || mime?.startsWith('video/mp4; codecs="vp08'))
) {
return media.CodecVP08;
} else if (
can.useVp9 &&
(codec === media.CodecVP09 || codec === media.FormatVP9 || mime?.startsWith('video/mp4; codecs="vp09'))
) {
return media.CodecVP09;
} else if (
can.useAv1 &&
(codec === media.CodecAV1 ||
codec === media.CodecAV1C ||
codec === media.FormatAV1 ||
mime?.startsWith('video/webm; codecs="av01'))
) {
return media.FormatAV1;
} else if (can.useWebM && (codec === media.FormatWebM || mime === media.ContentTypeWebM)) {
return media.FormatWebM;
}
return FormatAVC;
return media.FormatAVC;
}
static videoFormatUrl(hash, format) {
@@ -587,7 +598,7 @@ export default class Util {
}
if (!format) {
format = FormatAVC;
format = media.FormatAVC;
}
return `${config.videoUri}/videos/${hash}/${config.previewToken}/${format}`;
@@ -599,20 +610,28 @@ export default class Util {
static videoContentType(codec, mime) {
switch (this.videoFormat(codec, mime)) {
case FormatAVC:
return ContentTypeAVC;
case CodecOGV:
return ContentTypeOGG;
case CodecVP8:
return ContentTypeVP8;
case CodecVP9:
return ContentTypeVP9;
case FormatAV1:
return ContentTypeAV1;
case FormatWebM:
return ContentTypeWebM;
case FormatHEVC:
return ContentTypeHEVC;
case media.FormatAVC:
return media.ContentTypeAVC;
case media.CodecOGV:
return media.ContentTypeOGV;
case media.CodecVP08:
case media.FormatVP8:
return media.ContentTypeVP8;
case media.CodecVP09:
case media.FormatVP9:
return media.ContentTypeVP9;
case media.CodecAV1C:
case media.CodecAV1:
case media.FormatAV1:
return media.ContentTypeAV1;
case media.FormatWebM:
return media.ContentTypeWebM;
case media.CodecHEV1:
case media.CodecHEVC:
case media.FormatHEVC:
return media.ContentTypeHEVC;
case media.FormatVVC:
return media.ContentTypeVVC;
default:
return "video/mp4";
}

View File

@@ -27,8 +27,8 @@ import PhotoSwipeDynamicCaption from "photoswipe-dynamic-caption-plugin";
import Util from "common/util";
import Api from "common/api";
import Thumb from "model/thumb";
import { FormatAVC, MediaAnimated, MediaLive, Photo } from "model/photo";
import { ContentTypeAVC } from "common/caniuse";
import { Photo } from "model/photo";
import * as media from "common/media";
/*
TODO: All previously available features and controls must be preserved in the new hybrid photo/video viewer:
@@ -265,7 +265,7 @@ export default {
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.
loop: isShort || model?.Type === media.MediaAnimated || model?.Type === media.MediaLive, // If possible, loop these types.
msrc: img.src, // Image URL.
};
}
@@ -343,7 +343,12 @@ export default {
});
// Create and append video source elements, depending on file format support.
if (format !== FormatAVC && model?.Mime && model.Mime !== ContentTypeAVC && video.canPlayType(model.Mime)) {
if (
format !== media.FormatAVC &&
model?.Mime &&
model.Mime !== media.ContentTypeAVC &&
video.canPlayType(model.Mime)
) {
const nativeSource = document.createElement("source");
nativeSource.type = model.Mime;
nativeSource.src = Util.videoFormatUrl(model.Hash, format);
@@ -351,8 +356,8 @@ export default {
}
const avcSource = document.createElement("source");
avcSource.type = ContentTypeAVC;
avcSource.src = Util.videoFormatUrl(model.Hash, FormatAVC);
avcSource.type = media.ContentTypeAVC;
avcSource.src = Util.videoFormatUrl(model.Hash, media.FormatAVC);
video.appendChild(avcSource);
// Return HTMLMediaElement.
@@ -449,8 +454,8 @@ export default {
if (content.data?.type === "html" && content?.element) {
const data = content.data;
if (
data.model?.Type === MediaAnimated ||
data.model?.Type === MediaLive ||
data.model?.Type === media.MediaAnimated ||
data.model?.Type === media.MediaLive ||
this.slideshow.active ||
firstPicture
) {

View File

@@ -30,7 +30,7 @@ import Util from "common/util";
import { config } from "app/session";
import { $gettext } from "common/gettext";
import download from "common/download";
import { MediaImage } from "./photo";
import * as media from "common/media";
export class File extends RestModel {
getDefaults() {
@@ -199,7 +199,11 @@ export class File extends RestModel {
}
isAnimated() {
return this.MediaType && this.MediaType === MediaImage && ((this.Frames && this.Frames > 1) || (this.Duration && this.Duration > 1));
return (
this.MediaType &&
this.MediaType === media.MediaImage &&
((this.Frames && this.Frames > 1) || (this.Duration && this.Duration > 1))
);
}
typeInfo() {

View File

@@ -24,7 +24,6 @@ Additional information can be found in our Developer Guide:
*/
import memoizeOne from "memoize-one";
import RestModel from "model/rest";
import File from "model/file";
import Marker from "model/marker";
@@ -37,33 +36,8 @@ import { $gettext } from "common/gettext";
import { PhotoClipboard } from "common/clipboard";
import download from "common/download";
import * as src from "common/src";
import { ContentTypeAVC } from "common/caniuse";
import * as media from "common/media";
export const CodecOGV = "ogv";
export const CodecOGG = "ogg";
export const CodecVP8 = "vp8";
export const CodecVP9 = "vp9";
export const CodecAv01 = "av01";
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 FormatWebM = "webm";
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";
export const MediaLive = "live";
export const MediaVideo = "video";
export const MediaVector = "vector";
export const MediaSidecar = "sidecar";
export const YearUnknown = -1;
export const MonthUnknown = -1;
export const DayUnknown = -1;
@@ -104,7 +78,7 @@ export class Photo extends RestModel {
ID: "",
UID: "",
DocumentID: "",
Type: MediaImage,
Type: media.MediaImage,
TypeSrc: "",
Stack: 0,
Favorite: false,
@@ -424,7 +398,7 @@ export class Photo extends RestModel {
}
generateIsPlayable = memoizeOne((type, files) => {
if (type === MediaAnimated) {
if (type === media.MediaAnimated) {
return true;
} else if (!files) {
return false;
@@ -438,7 +412,7 @@ export class Photo extends RestModel {
}
generateIsStack = memoizeOne((type, files) => {
if (type !== MediaImage) {
if (type !== media.MediaImage) {
return false;
} else if (!files) {
return false;
@@ -449,7 +423,7 @@ export class Photo extends RestModel {
let jpegs = 0;
this.Files.forEach((f) => {
if (f && f.FileType === FormatJPEG) {
if (f && f.FileType === media.FormatJPEG) {
jpegs++;
}
});
@@ -504,7 +478,7 @@ export class Photo extends RestModel {
height = newHeight;
}
const loop = this.Type === MediaAnimated || (file.Duration >= 0 && file.Duration <= 5000000000);
const loop = this.Type === media.MediaAnimated || (file.Duration >= 0 && file.Duration <= 5000000000);
const poster = this.thumbnailUrl("fit_720");
const error = false;
@@ -520,10 +494,10 @@ export class Photo extends RestModel {
return false;
}
let file = files.find((f) => f.Codec === CodecAvc1);
let file = files.find((f) => f.Codec === media.CodecAVC);
if (!file) {
file = files.find((f) => f.FileType === FormatMP4);
file = files.find((f) => f.FileType === media.FormatMP4);
}
if (!file) {
@@ -542,7 +516,7 @@ 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 === media.FormatGIF || !!f.Frames || !!f.Duration);
}
videoContentType() {
@@ -551,7 +525,7 @@ export class Photo extends RestModel {
if (file) {
return Util.videoContentType(file?.Codec, file?.Mime);
} else {
return ContentTypeAVC;
return media.ContentTypeAVC;
}
}
@@ -579,7 +553,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 === media.FormatJPEG || f.FileType === media.FormatPNG);
// Found?
if (file) {
@@ -609,15 +583,17 @@ export class Photo extends RestModel {
// Find file with matching media type.
switch (this.Type) {
case MediaAnimated:
file = files.find((f) => f.MediaType === MediaImage && f.Root === "/");
case media.MediaAnimated:
file = files.find((f) => f.MediaType === media.MediaImage && f.Root === "/");
break;
case MediaLive:
file = files.find((f) => (f.MediaType === MediaVideo || f.MediaType === MediaLive) && f.Root === "/");
case media.MediaLive:
file = files.find(
(f) => (f.MediaType === media.MediaVideo || f.MediaType === media.MediaLive) && f.Root === "/"
);
break;
case MediaRaw:
case MediaVideo:
case MediaVector:
case media.MediaRaw:
case media.MediaVideo:
case media.MediaVector:
file = files.find((f) => f.MediaType === this.Type && f.Root === "/");
break;
}
@@ -628,7 +604,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 !== media.FormatJPEG && f.Root === "/");
// Found?
if (file) {
@@ -644,7 +620,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 === media.FormatJPEG || f.FileType === media.FormatPNG);
}
mainFileHash() {
@@ -751,21 +727,21 @@ export class Photo extends RestModel {
}
// Skip metadata sidecar files?
if (!s.download.mediaSidecar && (file.MediaType === MediaSidecar || file.Sidecar)) {
if (!s.download.mediaSidecar && (file.MediaType === media.MediaSidecar || file.Sidecar)) {
// Don't download broken files and sidecars.
if (config.debug) console.log(`download: skipped sidecar file ${file.Name}`);
return;
}
// Skip RAW images?
if (!s.download.mediaRaw && (file.MediaType === MediaRaw || file.FileType === MediaRaw)) {
if (!s.download.mediaRaw && (file.MediaType === media.MediaRaw || file.FileType === media.MediaRaw)) {
if (config.debug) console.log(`download: skipped raw file ${file.Name}`);
return;
}
// If this is a video, always skip stacked images...
// see https://github.com/photoprism/photoprism/issues/1436
if (this.Type === MediaVideo && !(file.MediaType === MediaVideo || file.Video)) {
if (this.Type === media.MediaVideo && !(file.MediaType === media.MediaVideo || file.Video)) {
if (config.debug) console.log(`download: skipped video sidecar ${file.Name}`);
return;
}
@@ -899,7 +875,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 === media.MediaVector || f.FileType === media.FormatSVG);
}
getVectorInfo = () => {
@@ -914,7 +890,7 @@ export class Photo extends RestModel {
const info = [];
if (file.MediaType === MediaVector) {
if (file.MediaType === media.MediaVector) {
info.push(Util.fileType(file.FileType));
} else {
info.push($gettext("Vector"));

View File

@@ -2,7 +2,7 @@ import { timeZonesNames } from "@vvo/tzdb";
import { $gettext } from "common/gettext";
import { Info } from "luxon";
import { config } from "app/session";
import { MediaImage, MediaLive, MediaVideo, MediaAnimated, MediaVector, MediaRaw } from "model/photo";
import * as media from "common/media";
export const GmtOffsets = [
{ ID: "GMT", Name: "Etc/GMT" },
@@ -354,27 +354,27 @@ export const MapsStyle = (experimental) => {
export const PhotoTypes = () => [
{
text: $gettext("Image"),
value: MediaImage,
value: media.MediaImage,
},
{
text: $gettext("Raw"),
value: MediaRaw,
value: media.MediaRaw,
},
{
text: $gettext("Animated"),
value: MediaAnimated,
value: media.MediaAnimated,
},
{
text: $gettext("Live"),
value: MediaLive,
value: media.MediaLive,
},
{
text: $gettext("Video"),
value: MediaVideo,
value: media.MediaVideo,
},
{
text: $gettext("Vector"),
value: MediaVector,
value: media.MediaVector,
},
];

View File

@@ -0,0 +1,39 @@
import "../fixtures";
import * as can from "common/can";
let chai = require("chai/chai");
let assert = chai.assert;
describe("common/can", () => {
it("canUseVideo", () => {
assert.equal(can.useVideo, true);
});
it("canUseAvc", () => {
assert.equal(can.useAvc, true);
});
it("canUseOGV", () => {
assert.equal(can.useOGV, true);
});
it("canUseVP8", () => {
assert.equal(can.useVp8, false);
});
it("canUseVP9", () => {
assert.equal(can.useVp9, true);
});
it("canUseAv1", () => {
assert.equal(can.useAv1, true);
});
it("canUseWebM", () => {
assert.equal(can.useWebM, true);
});
it("canUseHevc", () => {
assert.equal(can.useHevc, false);
});
});

View File

@@ -1,48 +0,0 @@
import "../fixtures";
import {
canUseAv1,
canUseAvc,
canUseHevc,
canUseOGV,
canUseVideo,
canUseVP8,
canUseVP9,
canUseWebM,
} from "common/caniuse";
let chai = require("chai/chai");
let assert = chai.assert;
describe("common/caniuse", () => {
it("canUseVideo", () => {
assert.equal(canUseVideo, true);
});
it("canUseAvc", () => {
assert.equal(canUseAvc, true);
});
it("canUseOGV", () => {
assert.equal(canUseOGV, true);
});
it("canUseVP8", () => {
assert.equal(canUseVP8, true);
});
it("canUseVP9", () => {
assert.equal(canUseVP9, true);
});
it("canUseAv1", () => {
assert.equal(canUseAv1, true);
});
it("canUseWebM", () => {
assert.equal(canUseWebM, true);
});
it("canUseHevc", () => {
assert.equal(canUseHevc, false);
});
});

View File

@@ -1,6 +1,6 @@
import "../fixtures";
import Util from "common/util";
import {canUseHevc, canUseWebM} from "common/caniuse";
import * as can from "common/can";
let chai = require("chai/chai");
let assert = chai.assert;
@@ -51,14 +51,14 @@ describe("common/util", () => {
assert.equal(avc, "avc");
const hvc = Util.videoFormat("hvc1", "video/mp4");
if (canUseHevc) {
if (can.useHevc) {
assert.equal(hvc, "hvc");
} else {
assert.equal(hvc, "avc");
}
const webm = Util.videoFormat("", "video/webm");
if (canUseWebM) {
if (can.useWebM) {
assert.equal(webm, "webm");
} else {
assert.equal(webm, "avc");

View File

@@ -1,6 +1,6 @@
import "../fixtures";
import { Photo, BatchSize, FormatJPEG } from "model/photo";
import { ContentTypeAVC } from "common/caniuse";
import * as media from "common/media";
import { Photo, BatchSize } from "model/photo";
let chai = require("chai/chai");
let assert = chai.assert;
@@ -762,7 +762,7 @@ describe("model/photo", () => {
],
};
const photo = new Photo(values);
assert.equal(photo.videoContentType(), ContentTypeAVC);
assert.equal(photo.videoContentType(), media.ContentTypeAVC);
assert.equal(photo.videoUrl(), "/api/v1/videos/703cf8f274fbb265d49c6262825780e1/public/avc");
const values2 = { ID: 9, UID: "ABC163", Hash: "2305e512e3b183ec982d60a8b608a8ca501973ba" };
const photo2 = new Photo(values2);
@@ -885,7 +885,7 @@ describe("model/photo", () => {
UID: "123fgb",
Name: "1980/01/superCuteKitten.jpg",
Primary: false,
FileType: FormatJPEG,
FileType: media.FormatJPEG,
Width: 500,
Height: 600,
Hash: "1xxbgdt55",

View File

@@ -1,6 +1,7 @@
package api
import (
_ "embed"
"net/http"
"strings"
@@ -11,8 +12,12 @@ import (
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/i18n"
"github.com/photoprism/photoprism/pkg/media/video"
)
//go:embed embed/video.mp4
var brokenVideo []byte
func Abort(c *gin.Context, code int, id i18n.Message, params ...interface{}) {
resp := i18n.NewResponse(code, id, params...)
@@ -110,3 +115,15 @@ func AbortInvalidCredentials(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": authn.ErrInvalidCredentials.Error(), "code": i18n.ErrInvalidCredentials, "message": i18n.Msg(i18n.ErrInvalidCredentials)})
}
}
func AbortVideo(c *gin.Context) {
if c != nil {
c.Data(http.StatusOK, video.ContentTypeAVC, brokenVideo)
}
}
func AbortVideoWithStatus(c *gin.Context, code int) {
if c != nil {
c.Data(code, video.ContentTypeAVC, brokenVideo)
}
}

Binary file not shown.

View File

@@ -14,6 +14,7 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media/video"
"github.com/photoprism/photoprism/pkg/rnd"
)
// GetVideo returns a video, optionally limited to a byte range for streaming.
@@ -31,47 +32,58 @@ import (
// @Router /api/v1/videos/{hash}/{token}/{format} [get]
func GetVideo(router *gin.RouterGroup) {
router.GET("/videos/:hash/:token/:format", func(c *gin.Context) {
fileHash := clean.Token(c.Param("hash"))
// Check if a valid security token was provided.
if InvalidPreviewToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", brokenIconSvg)
AbortVideoWithStatus(c, http.StatusForbidden)
return
}
fileHash := clean.Token(c.Param("hash"))
formatName := clean.Token(c.Param("format"))
// Check if a valid file hash was provided.
if !rnd.IsSHA(fileHash) {
AbortVideo(c)
return
}
// Check if a supported video format was provided.
formatName := clean.Token(c.Param("format"))
format, ok := video.Types[formatName]
if !ok {
log.Errorf("video: invalid format %s", clean.Log(formatName))
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
AbortVideo(c)
return
}
// Find media file by SHA hash.
f, err := query.FileByHash(fileHash)
if err != nil {
log.Errorf("video: requested file not found (%s)", err)
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
AbortVideo(c)
return
}
// If file is not a video, try to find the realted video file.
if !f.FileVideo {
f, err = query.VideoByPhotoUID(f.PhotoUID)
if err != nil {
log.Errorf("video: no playable file found (%s)", err)
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
AbortVideo(c)
return
}
}
// Return a broken video if the file could not be found.
if f.FileError != "" {
log.Errorf("video: file has error %s", f.FileError)
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
AbortVideo(c)
return
} else if f.FileHash == "" {
log.Errorf("video: file hash missing in index")
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
AbortVideo(c)
return
}
@@ -89,13 +101,11 @@ func GetVideo(router *gin.RouterGroup) {
if info, videoErr := video.ProbeFile(videoFileName); info.VideoOffset < 0 || !info.Compatible || videoErr != nil {
logErr("video", videoErr)
log.Warnf("video: no embedded media found in %s", clean.Log(f.FileName))
AddContentTypeHeader(c, video.ContentTypeAVC)
c.File(get.Config().StaticFile("video/404.mp4"))
AbortVideo(c)
return
} else if reader, readErr := video.NewReader(videoFileName, info.VideoOffset); readErr != nil {
log.Errorf("video: failed to read media embedded in %s (%s)", clean.Log(f.FileName), readErr)
AddContentTypeHeader(c, video.ContentTypeAVC)
c.File(get.Config().StaticFile("video/404.mp4"))
AbortVideo(c)
return
} else if c.Request.Header.Get("Range") == "" && info.VideoCodec == format.Codec {
defer reader.Close()
@@ -104,8 +114,7 @@ func GetVideo(router *gin.RouterGroup) {
return
} else if cacheName, cacheErr := fs.CacheFileFromReader(filepath.Join(conf.MediaFileCachePath(f.FileHash), f.FileHash+info.VideoFileExt()), reader); cacheErr != nil {
log.Errorf("video: failed to cache %s embedded in %s (%s)", strings.ToUpper(videoFileType), clean.Log(f.FileName), cacheErr)
AddContentTypeHeader(c, video.ContentTypeAVC)
c.File(get.Config().StaticFile("video/404.mp4"))
AbortVideo(c)
return
} else {
// Serve embedded videos from cache to allow streaming and transcoding.
@@ -129,8 +138,8 @@ func GetVideo(router *gin.RouterGroup) {
// Log error and default to 404.mp4
log.Errorf("video: file %s is missing", clean.Log(f.FileName))
videoFileName = get.Config().StaticFile("video/404.mp4")
AddContentTypeHeader(c, video.ContentTypeAVC)
AbortVideo(c)
return
} else if transcode {
if videoCodec != "" {
log.Debugf("video: %s is %s encoded and cannot be streamed directly, average bitrate %.1f MBit/s", clean.Log(f.FileName), strings.ToUpper(videoCodec), videoBitrate)
@@ -142,13 +151,13 @@ func GetVideo(router *gin.RouterGroup) {
if avcFile, avcErr := conv.ToAvc(mediaFile, get.Config().FFmpegEncoder(), false, false); avcFile != nil && avcErr == nil {
videoFileName = avcFile.FileName()
AddContentTypeHeader(c, video.ContentTypeAVC)
} else {
// Log error and default to 404.mp4
log.Errorf("video: failed to transcode %s", clean.Log(f.FileName))
videoFileName = get.Config().StaticFile("video/404.mp4")
AbortVideo(c)
return
}
AddContentTypeHeader(c, video.ContentTypeAVC)
} 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)

View File

@@ -17,13 +17,27 @@ func TestGetVideo(t *testing.T) {
assert.Equal(t, video.ContentTypeAVC, fmt.Sprintf("%s; codecs=\"%s\"", "video/mp4", clean.Codec("avc1")))
})
t.Run("NoHash", func(t *testing.T) {
app, router, conf := NewApiTest()
GetVideo(router)
r := PerformRequest(app, "GET", "/api/v1/videos//"+conf.PreviewToken()+"/mp4")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("InvalidHash", func(t *testing.T) {
app, router, conf := NewApiTest()
GetVideo(router)
r := PerformRequest(app, "GET", "/api/v1/videos/xxx/"+conf.PreviewToken()+"/mp4")
r := PerformRequest(app, "GET", "/api/v1/videos/acad9168fa6/"+conf.PreviewToken()+"/mp4")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("NoType", func(t *testing.T) {
app, router, conf := NewApiTest()
GetVideo(router)
r := PerformRequest(app, "GET", "/api/v1/videos/acad9168fa6acc5c5c2965ddf6ec465ca42fd831/"+conf.PreviewToken()+"/")
assert.Equal(t, http.StatusMovedPermanently, r.Code)
})
t.Run("InvalidType", func(t *testing.T) {
app, router, conf := NewApiTest()
GetVideo(router)

View File

@@ -902,17 +902,21 @@ func (m *File) ContentType() (contentType string) {
switch codec {
case "mov", "mp4", "avc", video.CodecAVC:
contentType = header.ContentTypeAVC
case video.CodecHEVC:
contentType = header.ContentTypeHEVC
case video.CodecVP8, "vp08":
contentType = header.ContentTypeAVC // Advanced Video Coding (AVC), also known as H.264
case "hev", "hev1", "hevc", video.CodecHEVC:
contentType = header.ContentTypeHEVC // High Efficiency Video Coding (HEVC), also known as H.265
case "vvc", video.CodecVVC:
contentType = header.ContentTypeVVC // Versatile Video Coding (VVC), also known as H.266
case "evc", video.CodecEVC:
contentType = header.ContentTypeEVC // MPEG-5 Essential Video Coding (EVC), also known as ISO/IEC 23094-1
case "vp8", video.CodecVP8:
contentType = header.ContentTypeVP8
case video.CodecVP9, "vp09":
case "vp9", video.CodecVP9:
contentType = header.ContentTypeVP9
case "av1", "av01":
case "av1", video.CodecAV1:
contentType = header.ContentTypeAV1
case "ogg":
contentType = header.ContentTypeOGG
case "ogg", video.CodecOGV:
contentType = header.ContentTypeOGV
case "webm":
contentType = header.ContentTypeWebM
}

View File

@@ -1,6 +1,8 @@
package clean
import (
"strings"
"github.com/photoprism/photoprism/pkg/header"
)
@@ -12,6 +14,9 @@ func ContentType(s string) string {
s = Type(s)
// Replace "video/quicktime" with "video/mp4" as the container formats are largely compatible.
s = strings.Replace(s, "video/quicktime", "video/mp4", 1)
switch s {
case "":
return header.ContentTypeBinary
@@ -21,7 +26,10 @@ func ContentType(s string) string {
return header.ContentTypeHtml
case "text/plain":
return header.ContentTypeText
case "text/pdf", "text/x-pdf", "application/x-pdf", "application/acrobat":
case "text/pdf",
"text/x-pdf",
"application/x-pdf",
"application/acrobat":
return header.ContentTypePDF
case "image/svg":
return header.ContentTypeSVG
@@ -29,9 +37,20 @@ func ContentType(s string) string {
return header.ContentTypeJPEG
case "video/mp4; codecs=\"avc\"":
return header.ContentTypeAVC
case "video/mp4; codecs=\"hvc1\"", "video/mp4; codecs=\"hvc\"", "video/mp4; codecs=\"hevc\"":
case "video/mp4; codecs=\"hvc\"",
"video/mp4; codecs=\"hvc1\"",
"video/mp4; codecs=\"hev\"",
"video/mp4; codecs=\"hev1\"",
"video/mp4; codecs=\"hevc\"":
return header.ContentTypeHEVC
case "video/webm; codecs=\"av01\"":
case "video/webm; codecs=\"vp8\"",
"video/webm; codecs=\"vp08\"":
return header.ContentTypeVP8
case "video/webm; codecs=\"vp9\"",
"video/webm; codecs=\"vp09\"":
return header.ContentTypeVP9
case "video/webm; codecs=\"av1\"",
"video/webm; codecs=\"av01\"":
return header.ContentTypeAV1
}

View File

@@ -16,6 +16,9 @@ const (
)
// Standard ContentType header values.
//
// See https://dmnsgn.github.io/media-codecs/ and https://cconcolato.github.io/media-mime-support/
// for codec support in browsers and the corresponding profile strings to use in the content type.
const (
ContentTypeBinary = "application/octet-stream"
ContentTypeForm = "application/x-www-form-urlencoded"
@@ -28,11 +31,13 @@ const (
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"
ContentTypeAVC = "video/mp4; codecs=\"avc1\"" // Advanced Video Coding (AVC), also known as H.264
ContentTypeHEVC = "video/mp4; codecs=\"hev1.2.4.L120.B0\"" // HEVC Main10 Profile, Main Tier, Level 4.0
ContentTypeVVC = "video/mp4; codecs=\"vvc1\"" // Versatile Video Coding (VVC), also known as H.266
ContentTypeEVC = "video/mp4; codecs=\"evc1\"" // MPEG-5 Essential Video Coding (EVC), also known as ISO/IEC 23094-1
ContentTypeOGV = "video/ogg"
ContentTypeWebM = "video/webm"
ContentTypeVP8 = "video/webm; codecs=\"vp8\""
ContentTypeVP9 = "video/webm; codecs=\"vp9\""
ContentTypeAV1 = "video/webm; codecs=\"av01.0.08M.08\""
ContentTypeVP8 = "video/webm; codecs=\"vp08.02.41.10\""
ContentTypeVP9 = "video/webm; codecs=\"vp09.00.50.08\""
ContentTypeAV1 = "video/webm; codecs=\"av01.2.10M.10\""
)

View File

@@ -6,15 +6,15 @@ type Codec = string
// https://cconcolato.github.io/media-mime-support/
const (
CodecUnknown Codec = ""
CodecAVC Codec = "avc1"
CodecHEVC Codec = "hvc1"
CodecVVC Codec = "vvc"
CodecEVC Codec = "evc"
CodecAV1 Codec = "av01"
CodecVP8 Codec = "vp8"
CodecVP9 Codec = "vp9"
CodecOGV Codec = "ogv"
CodecWebM Codec = "webm"
CodecAVC Codec = "avc1" // Advanced Video Coding (AVC), also known as H.264
CodecHEVC Codec = "hvc1" // High Efficiency Video Coding (HEVC), also known as H.265
CodecVVC Codec = "vvc1" // Versatile Video Coding (VVC), also known as H.266
CodecEVC Codec = "evc1" // MPEG-5 Essential Video Coding (EVC), also known as ISO/IEC 23094-1
CodecAV1 Codec = "av01" // AOMedia Video 1 (AV1)
CodecVP8 Codec = "vp08" // Google VP8
CodecVP9 Codec = "vp09" // Google VP9
CodecOGV Codec = "ogv" // Ogg Vorbis Video
CodecWebM Codec = "webm" // Google WebM
)
// Codecs maps identifiers to codecs.
@@ -52,6 +52,7 @@ var Codecs = StandardCodecs{
"v_av1": CodecAV1,
"v_av01": CodecAV1,
"vp8": CodecVP8,
"vp08": CodecVP8,
"vp80": CodecVP8,
"v_vp8": CodecVP8,
"vp9": CodecVP9,

View File

@@ -22,8 +22,10 @@ var Types = Standards{
"vvc1": VVC,
"vvcC": VVC,
"vp8": VP8,
"vp08": VP8,
"vp80": VP8,
"vp9": VP9,
"vp09": VP9,
"vp90": VP9,
"av1": AV1,
"av01": AV1,

View File

@@ -10,7 +10,22 @@ func IsMD5(s string) bool {
return IsHex(s)
}
// IsSHA checks if the string appears to be a standard length SHA hash.
//
// Examples:
// - de9f2c7fd25e1b3afad3e85a0bd17d9b100db4b3
// - d14a028c2a3a2bc9476102bb288234c415a2b01f828ea62ac5b3e42f
// - e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
func IsSHA(s string) bool {
if l := len(s); l < 40 || l%8 != 0 {
return false
}
return IsHex(s)
}
// IsSHA1 checks if the string appears to be a SHA1 hash.
//
// Example: de9f2c7fd25e1b3afad3e85a0bd17d9b100db4b3
func IsSHA1(s string) bool {
if len(s) != 40 {
@@ -21,6 +36,7 @@ func IsSHA1(s string) bool {
}
// IsSHA224 checks if the string appears to be a SHA224 hash.
//
// Example: d14a028c2a3a2bc9476102bb288234c415a2b01f828ea62ac5b3e42f
func IsSHA224(s string) bool {
if len(s) != 56 {
@@ -31,6 +47,7 @@ func IsSHA224(s string) bool {
}
// IsSHA256 checks if the string appears to be a SHA256 hash.
//
// Example: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
func IsSHA256(s string) bool {
if len(s) != 64 {
@@ -41,6 +58,7 @@ func IsSHA256(s string) bool {
}
// IsSHA384 checks if the string appears to be a SHA384 hash.
//
// Example: 38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b
func IsSHA384(s string) bool {
if len(s) != 96 {
@@ -52,6 +70,7 @@ func IsSHA384(s string) bool {
}
// IsSHA512 checks if the string appears to be a SHA512 hash.
//
// Example: cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e
func IsSHA512(s string) bool {
if len(s) != 128 {

View File

@@ -7,6 +7,17 @@ import (
)
func TestIsSHA(t *testing.T) {
t.Run("Any", func(t *testing.T) {
assert.False(t, IsSHA(""))
assert.False(t, IsSHA("de9f2c7fd25e1b3afad3e85a0bd17d9b100db4b"))
assert.True(t, IsSHA("de9f2c7fd25e1b3afad3e85a0bd17d9b100db4b3"))
assert.False(t, IsSHA("de9f2c7fd25e1b3afad3e85a0bd17d9b100db4b32"))
assert.True(t, IsSHA("d14a028c2a3a2bc9476102bb288234c415a2b01f828ea62ac5b3e42f"))
assert.False(t, IsSHA("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b85"))
assert.True(t, IsSHA("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"))
assert.True(t, IsSHA("38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b"))
assert.True(t, IsSHA("cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"))
})
t.Run("SHA1", func(t *testing.T) {
assert.False(t, IsSHA1(""))
assert.True(t, IsSHA1("de9f2c7fd25e1b3afad3e85a0bd17d9b100db4b3"))

View File

@@ -3,6 +3,7 @@ package txt
var SpecialWords = map[string]string{
"av": "AV",
"av1": "AV1",
"aomedia": "AOMedia",
"vp": "VP",
"vp8": "VP8",
"vp9": "VP9",
@@ -124,6 +125,8 @@ var SpecialWords = map[string]string{
"mov": "MOV",
"avc": "AVC",
"hvc": "HVC",
"vvc": "VVC",
"evc": "EVC",
"hevc": "HEVC",
"heif": "HEIF",
"heic": "HEIC",