mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -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",
|
||||
|
||||
45
frontend/package-lock.json
generated
45
frontend/package-lock.json
generated
@@ -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"
|
||||
|
||||
52
frontend/src/common/can.js
Normal file
52
frontend/src/common/can.js
Normal 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;
|
||||
@@ -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;
|
||||
43
frontend/src/common/media.js
Normal file
43
frontend/src/common/media.js
Normal 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"';
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
39
frontend/tests/unit/common/can_test.js
Normal file
39
frontend/tests/unit/common/can_test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
BIN
internal/api/embed/video.mp4
Normal file
BIN
internal/api/embed/video.mp4
Normal file
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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\""
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user