perf: move multipreview to app
parent
3f61f9484f
commit
dd976d3c68
|
@ -44,8 +44,8 @@
|
||||||
@touchend.passive="$emit('touchend', $event)"
|
@touchend.passive="$emit('touchend', $event)"
|
||||||
@touchcancel.passive="$emit('touchend', $event)"
|
@touchcancel.passive="$emit('touchend', $event)"
|
||||||
>
|
>
|
||||||
<img
|
<XImg
|
||||||
ref="img"
|
ref="ximg"
|
||||||
:class="['fill-block', `memories-thumb-${data.key}`]"
|
:class="['fill-block', `memories-thumb-${data.key}`]"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
:src="src"
|
:src="src"
|
||||||
|
@ -73,6 +73,8 @@ import { getPreviewUrl } from "../../services/FileUtils";
|
||||||
import { IDay, IPhoto } from "../../types";
|
import { IDay, IPhoto } from "../../types";
|
||||||
import * as utils from "../../services/Utils";
|
import * as utils from "../../services/Utils";
|
||||||
|
|
||||||
|
import XImg from "./XImg.vue";
|
||||||
|
|
||||||
import errorsvg from "../../assets/error.svg";
|
import errorsvg from "../../assets/error.svg";
|
||||||
import CheckCircle from "vue-material-design-icons/CheckCircle.vue";
|
import CheckCircle from "vue-material-design-icons/CheckCircle.vue";
|
||||||
import Star from "vue-material-design-icons/Star.vue";
|
import Star from "vue-material-design-icons/Star.vue";
|
||||||
|
@ -82,6 +84,7 @@ import LivePhoto from "vue-material-design-icons/MotionPlayOutline.vue";
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: "Photo",
|
name: "Photo",
|
||||||
components: {
|
components: {
|
||||||
|
XImg,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Video,
|
Video,
|
||||||
Star,
|
Star,
|
||||||
|
@ -205,7 +208,7 @@ export default defineComponent({
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
const img = this.$refs.img as HTMLImageElement;
|
const img = (this.$refs.ximg as any).$el as HTMLImageElement;
|
||||||
|
|
||||||
canvas.width = img.naturalWidth;
|
canvas.width = img.naturalWidth;
|
||||||
canvas.height = img.naturalHeight;
|
canvas.height = img.naturalHeight;
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
<template>
|
||||||
|
<img :alt="alt" :src="dataSrc" @load="load" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import { fetchImage } from "./XImgCache";
|
||||||
|
|
||||||
|
const BLANK_IMG =
|
||||||
|
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "XImg",
|
||||||
|
props: {
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
alt: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data: () => {
|
||||||
|
return {
|
||||||
|
dataSrc: BLANK_IMG,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
src() {
|
||||||
|
this.loadImage();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.loadImage();
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
async loadImage() {
|
||||||
|
if (!this.src) return;
|
||||||
|
|
||||||
|
// Just set src if not http
|
||||||
|
if (this.src.startsWith("data:") || this.src.startsWith("blob:")) {
|
||||||
|
this.dataSrc = this.src;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch image with axios
|
||||||
|
try {
|
||||||
|
this.dataSrc = URL.createObjectURL(await fetchImage(this.src));
|
||||||
|
} catch (error) {
|
||||||
|
this.dataSrc = BLANK_IMG;
|
||||||
|
this.$emit("error", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
load() {
|
||||||
|
if (this.dataSrc === BLANK_IMG) return;
|
||||||
|
this.$emit("load", this.dataSrc);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
|
@ -1,8 +1,10 @@
|
||||||
import { registerRoute } from "workbox-routing";
|
|
||||||
import { CacheExpiration } from "workbox-expiration";
|
import { CacheExpiration } from "workbox-expiration";
|
||||||
|
import { API } from "../../services/API";
|
||||||
|
import axios from "@nextcloud/axios";
|
||||||
|
|
||||||
// Queue of requests to fetch preview images
|
// Queue of requests to fetch preview images
|
||||||
interface FetchPreviewObject {
|
interface FetchPreviewObject {
|
||||||
|
origUrl: string;
|
||||||
url: URL;
|
url: URL;
|
||||||
fileid: number;
|
fileid: number;
|
||||||
reqid: number;
|
reqid: number;
|
||||||
|
@ -35,7 +37,7 @@ async function flushPreviewQueue() {
|
||||||
// Check if only one request
|
// Check if only one request
|
||||||
if (fetchPreviewQueueCopy.length === 1) {
|
if (fetchPreviewQueueCopy.length === 1) {
|
||||||
const p = fetchPreviewQueueCopy[0];
|
const p = fetchPreviewQueueCopy[0];
|
||||||
return p.callback(await fetch(p.url));
|
return p.callback(await fetchOneImage(p.origUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create aggregated request body
|
// Create aggregated request body
|
||||||
|
@ -48,26 +50,15 @@ async function flushPreviewQueue() {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// infer the url from the first file
|
|
||||||
const firstUrl = fetchPreviewQueueCopy[0].url;
|
|
||||||
const url = new URL(firstUrl.toString());
|
|
||||||
const path = url.pathname.split("/");
|
|
||||||
const previewIndex = path.indexOf("preview");
|
|
||||||
url.pathname = path.slice(0, previewIndex).join("/") + "/multipreview";
|
|
||||||
url.searchParams.delete("x");
|
|
||||||
url.searchParams.delete("y");
|
|
||||||
url.searchParams.delete("a");
|
|
||||||
url.searchParams.delete("c");
|
|
||||||
|
|
||||||
// Fetch multipreview
|
// Fetch multipreview
|
||||||
const res = await fetch(url, {
|
const multiUrl = API.IMAGE_MULTIPREVIEW();
|
||||||
method: "POST",
|
const res = await axios.post(multiUrl, files, {
|
||||||
body: JSON.stringify(files),
|
responseType: "blob",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get blob
|
// Get blob
|
||||||
if (res.status !== 200) throw new Error("Error fetching multi-preview");
|
if (res.status !== 200) throw new Error("Error fetching multi-preview");
|
||||||
const blob = await res.blob();
|
const blob = res.data;
|
||||||
|
|
||||||
let idx = 0;
|
let idx = 0;
|
||||||
while (idx < blob.size) {
|
while (idx < blob.size) {
|
||||||
|
@ -80,8 +71,6 @@ async function flushPreviewQueue() {
|
||||||
const reqid = jsonParsed["reqid"];
|
const reqid = jsonParsed["reqid"];
|
||||||
idx += newlineIndex + 1;
|
idx += newlineIndex + 1;
|
||||||
|
|
||||||
console.debug("multi-preview", jsonParsed);
|
|
||||||
|
|
||||||
// Read the image data
|
// Read the image data
|
||||||
const imgBlob = blob.slice(idx, idx + imgLen);
|
const imgBlob = blob.slice(idx, idx + imgLen);
|
||||||
idx += imgLen;
|
idx += imgLen;
|
||||||
|
@ -91,14 +80,11 @@ async function flushPreviewQueue() {
|
||||||
.filter((p) => p.reqid === reqid)
|
.filter((p) => p.reqid === reqid)
|
||||||
.forEach((p) => {
|
.forEach((p) => {
|
||||||
p.callback(
|
p.callback(
|
||||||
new Response(imgBlob, {
|
getResponse(imgBlob, imgType, {
|
||||||
status: 200,
|
"Content-Type": imgType,
|
||||||
headers: {
|
"Content-Length": imgLen,
|
||||||
"Content-Type": imgType,
|
"Cache-Control": res.headers["Cache-Control"],
|
||||||
"Content-Length": imgLen,
|
Expires: res.headers["Expires"],
|
||||||
Expires: res.headers.get("Expires"),
|
|
||||||
"Cache-Control": res.headers.get("Cache-Control"),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
p.callback = null;
|
p.callback = null;
|
||||||
|
@ -119,46 +105,70 @@ async function flushPreviewQueue() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intercept preview requests
|
/** Accepts a URL and returns a promise with a blob */
|
||||||
registerRoute(
|
export async function fetchImage(url: string): Promise<Blob> {
|
||||||
/^.*\/apps\/memories\/api\/image\/preview\/.*/,
|
// Check if in cache
|
||||||
async ({ url, request }) => {
|
const cache = await imageCache?.match(url);
|
||||||
// Check if in cache
|
if (cache) return await cache.blob();
|
||||||
const cache = await imageCache?.match(url);
|
|
||||||
if (cache) return cache;
|
|
||||||
|
|
||||||
// Get file id from URL
|
// Get file id from URL
|
||||||
const fileid = Number(url.pathname.split("/").pop());
|
const urlObj = new URL(url, window.location.origin);
|
||||||
|
const fileid = Number(urlObj.pathname.split("/").pop());
|
||||||
|
|
||||||
// Aggregate requests
|
// Check if preview image
|
||||||
let res: Response = await new Promise((callback) => {
|
const regex = /^.*\/apps\/memories\/api\/image\/preview\/.*/;
|
||||||
|
|
||||||
|
// Aggregate requests
|
||||||
|
let res: Response;
|
||||||
|
|
||||||
|
if (regex.test(url)) {
|
||||||
|
res = await new Promise((callback) => {
|
||||||
fetchPreviewQueue.push({
|
fetchPreviewQueue.push({
|
||||||
url,
|
origUrl: url,
|
||||||
|
url: urlObj,
|
||||||
fileid,
|
fileid,
|
||||||
reqid: Math.random(),
|
reqid: Math.random(),
|
||||||
callback,
|
callback,
|
||||||
});
|
});
|
||||||
if (!fetchPreviewTimer) {
|
if (!fetchPreviewTimer) {
|
||||||
fetchPreviewTimer = setTimeout(flushPreviewQueue, 50);
|
fetchPreviewTimer = setTimeout(flushPreviewQueue, 10);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fallback to single request
|
|
||||||
if (res.status !== 200) {
|
|
||||||
res = await fetch(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache response
|
|
||||||
if (res.status === 200) {
|
|
||||||
imageCache?.put(request, res.clone());
|
|
||||||
expirationManager.updateTimestamp(request.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run expiration once in every 20 requests
|
|
||||||
if (Math.random() < 0.05) {
|
|
||||||
expirationManager.expireEntries();
|
|
||||||
}
|
|
||||||
|
|
||||||
return res;
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
// Fallback to single request
|
||||||
|
if (!res || res.status !== 200) {
|
||||||
|
res = await fetchOneImage(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache response
|
||||||
|
if (res.status === 200) {
|
||||||
|
imageCache?.put(url, res.clone());
|
||||||
|
expirationManager.updateTimestamp(url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run expiration once in every 100 requests
|
||||||
|
if (Math.random() < 0.01) {
|
||||||
|
expirationManager.expireEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
return await res.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOneImage(url: string) {
|
||||||
|
const res = await axios.get(url, {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
return getResponse(res.data, res.headers["content-type"], res.headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResponse(blob: Blob, type: string, headers: any = {}) {
|
||||||
|
return new Response(blob, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": type,
|
||||||
|
"Content-Length": blob.size.toString(),
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
|
@ -5,10 +5,9 @@ import { ExpirationPlugin } from 'workbox-expiration';
|
||||||
|
|
||||||
precacheAndRoute(self.__WB_MANIFEST);
|
precacheAndRoute(self.__WB_MANIFEST);
|
||||||
|
|
||||||
import './service-worker-custom';
|
|
||||||
|
|
||||||
registerRoute(/^.*\/apps\/memories\/api\/video\/transcode\/.*/, new NetworkOnly());
|
registerRoute(/^.*\/apps\/memories\/api\/video\/transcode\/.*/, new NetworkOnly());
|
||||||
registerRoute(/^.*\/apps\/memories\/api\/image\/jpeg\/.*/, new NetworkOnly());
|
registerRoute(/^.*\/apps\/memories\/api\/image\/jpeg\/.*/, new NetworkOnly());
|
||||||
|
registerRoute(/^.*\/apps\/memories\/api\/image\/preview\/.*/, new NetworkOnly());
|
||||||
registerRoute(/^.*\/remote.php\/.*/, new NetworkOnly());
|
registerRoute(/^.*\/remote.php\/.*/, new NetworkOnly());
|
||||||
registerRoute(/^.*\/apps\/files\/ajax\/download.php?.*/, new NetworkOnly());
|
registerRoute(/^.*\/apps\/files\/ajax\/download.php?.*/, new NetworkOnly());
|
||||||
|
|
||||||
|
@ -22,7 +21,6 @@ const imageCache = new CacheFirst({
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
registerRoute(/^.*\/apps\/memories\/api\/image\/preview\/.*/, imageCache);
|
|
||||||
registerRoute(/^.*\/apps\/memories\/api\/video\/livephoto\/.*/, imageCache);
|
registerRoute(/^.*\/apps\/memories\/api\/video\/livephoto\/.*/, imageCache);
|
||||||
registerRoute(/^.*\/apps\/memories\/api\/faces\/preview\/.*/, imageCache);
|
registerRoute(/^.*\/apps\/memories\/api\/faces\/preview\/.*/, imageCache);
|
||||||
registerRoute(/^.*\/apps\/memories\/api\/tags\/preview\/.*/, imageCache);
|
registerRoute(/^.*\/apps\/memories\/api\/tags\/preview\/.*/, imageCache);
|
||||||
|
|
|
@ -82,6 +82,10 @@ export class API {
|
||||||
return tok(gen(`${BASE}/image/preview/{fileid}`, { fileid }));
|
return tok(gen(`${BASE}/image/preview/{fileid}`, { fileid }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static IMAGE_MULTIPREVIEW() {
|
||||||
|
return tok(gen(`${BASE}/image/multipreview`));
|
||||||
|
}
|
||||||
|
|
||||||
static IMAGE_INFO(id: number) {
|
static IMAGE_INFO(id: number) {
|
||||||
return tok(gen(`${BASE}/image/info/{id}`, { id }));
|
return tok(gen(`${BASE}/image/info/{id}`, { id }));
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue