perf: move multipreview to app
parent
3f61f9484f
commit
dd976d3c68
|
@ -44,8 +44,8 @@
|
|||
@touchend.passive="$emit('touchend', $event)"
|
||||
@touchcancel.passive="$emit('touchend', $event)"
|
||||
>
|
||||
<img
|
||||
ref="img"
|
||||
<XImg
|
||||
ref="ximg"
|
||||
:class="['fill-block', `memories-thumb-${data.key}`]"
|
||||
draggable="false"
|
||||
:src="src"
|
||||
|
@ -73,6 +73,8 @@ import { getPreviewUrl } from "../../services/FileUtils";
|
|||
import { IDay, IPhoto } from "../../types";
|
||||
import * as utils from "../../services/Utils";
|
||||
|
||||
import XImg from "./XImg.vue";
|
||||
|
||||
import errorsvg from "../../assets/error.svg";
|
||||
import CheckCircle from "vue-material-design-icons/CheckCircle.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({
|
||||
name: "Photo",
|
||||
components: {
|
||||
XImg,
|
||||
CheckCircle,
|
||||
Video,
|
||||
Star,
|
||||
|
@ -205,7 +208,7 @@ export default defineComponent({
|
|||
|
||||
const canvas = document.createElement("canvas");
|
||||
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.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 { API } from "../../services/API";
|
||||
import axios from "@nextcloud/axios";
|
||||
|
||||
// Queue of requests to fetch preview images
|
||||
interface FetchPreviewObject {
|
||||
origUrl: string;
|
||||
url: URL;
|
||||
fileid: number;
|
||||
reqid: number;
|
||||
|
@ -35,7 +37,7 @@ async function flushPreviewQueue() {
|
|||
// Check if only one request
|
||||
if (fetchPreviewQueueCopy.length === 1) {
|
||||
const p = fetchPreviewQueueCopy[0];
|
||||
return p.callback(await fetch(p.url));
|
||||
return p.callback(await fetchOneImage(p.origUrl));
|
||||
}
|
||||
|
||||
// Create aggregated request body
|
||||
|
@ -48,26 +50,15 @@ async function flushPreviewQueue() {
|
|||
}));
|
||||
|
||||
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
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(files),
|
||||
const multiUrl = API.IMAGE_MULTIPREVIEW();
|
||||
const res = await axios.post(multiUrl, files, {
|
||||
responseType: "blob",
|
||||
});
|
||||
|
||||
// Get blob
|
||||
if (res.status !== 200) throw new Error("Error fetching multi-preview");
|
||||
const blob = await res.blob();
|
||||
const blob = res.data;
|
||||
|
||||
let idx = 0;
|
||||
while (idx < blob.size) {
|
||||
|
@ -80,8 +71,6 @@ async function flushPreviewQueue() {
|
|||
const reqid = jsonParsed["reqid"];
|
||||
idx += newlineIndex + 1;
|
||||
|
||||
console.debug("multi-preview", jsonParsed);
|
||||
|
||||
// Read the image data
|
||||
const imgBlob = blob.slice(idx, idx + imgLen);
|
||||
idx += imgLen;
|
||||
|
@ -91,14 +80,11 @@ async function flushPreviewQueue() {
|
|||
.filter((p) => p.reqid === reqid)
|
||||
.forEach((p) => {
|
||||
p.callback(
|
||||
new Response(imgBlob, {
|
||||
status: 200,
|
||||
headers: {
|
||||
getResponse(imgBlob, imgType, {
|
||||
"Content-Type": imgType,
|
||||
"Content-Length": imgLen,
|
||||
Expires: res.headers.get("Expires"),
|
||||
"Cache-Control": res.headers.get("Cache-Control"),
|
||||
},
|
||||
"Cache-Control": res.headers["Cache-Control"],
|
||||
Expires: res.headers["Expires"],
|
||||
})
|
||||
);
|
||||
p.callback = null;
|
||||
|
@ -119,46 +105,70 @@ async function flushPreviewQueue() {
|
|||
});
|
||||
}
|
||||
|
||||
// Intercept preview requests
|
||||
registerRoute(
|
||||
/^.*\/apps\/memories\/api\/image\/preview\/.*/,
|
||||
async ({ url, request }) => {
|
||||
/** Accepts a URL and returns a promise with a blob */
|
||||
export async function fetchImage(url: string): Promise<Blob> {
|
||||
// Check if in cache
|
||||
const cache = await imageCache?.match(url);
|
||||
if (cache) return cache;
|
||||
if (cache) return await cache.blob();
|
||||
|
||||
// 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());
|
||||
|
||||
// Check if preview image
|
||||
const regex = /^.*\/apps\/memories\/api\/image\/preview\/.*/;
|
||||
|
||||
// Aggregate requests
|
||||
let res: Response = await new Promise((callback) => {
|
||||
let res: Response;
|
||||
|
||||
if (regex.test(url)) {
|
||||
res = await new Promise((callback) => {
|
||||
fetchPreviewQueue.push({
|
||||
url,
|
||||
origUrl: url,
|
||||
url: urlObj,
|
||||
fileid,
|
||||
reqid: Math.random(),
|
||||
callback,
|
||||
});
|
||||
if (!fetchPreviewTimer) {
|
||||
fetchPreviewTimer = setTimeout(flushPreviewQueue, 50);
|
||||
fetchPreviewTimer = setTimeout(flushPreviewQueue, 10);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback to single request
|
||||
if (res.status !== 200) {
|
||||
res = await fetch(url);
|
||||
if (!res || res.status !== 200) {
|
||||
res = await fetchOneImage(url);
|
||||
}
|
||||
|
||||
// Cache response
|
||||
if (res.status === 200) {
|
||||
imageCache?.put(request, res.clone());
|
||||
expirationManager.updateTimestamp(request.url);
|
||||
imageCache?.put(url, res.clone());
|
||||
expirationManager.updateTimestamp(url.toString());
|
||||
}
|
||||
|
||||
// Run expiration once in every 20 requests
|
||||
if (Math.random() < 0.05) {
|
||||
// Run expiration once in every 100 requests
|
||||
if (Math.random() < 0.01) {
|
||||
expirationManager.expireEntries();
|
||||
}
|
||||
|
||||
return res;
|
||||
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);
|
||||
|
||||
import './service-worker-custom';
|
||||
|
||||
registerRoute(/^.*\/apps\/memories\/api\/video\/transcode\/.*/, new NetworkOnly());
|
||||
registerRoute(/^.*\/apps\/memories\/api\/image\/jpeg\/.*/, new NetworkOnly());
|
||||
registerRoute(/^.*\/apps\/memories\/api\/image\/preview\/.*/, new NetworkOnly());
|
||||
registerRoute(/^.*\/remote.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\/faces\/preview\/.*/, imageCache);
|
||||
registerRoute(/^.*\/apps\/memories\/api\/tags\/preview\/.*/, imageCache);
|
||||
|
|
|
@ -82,6 +82,10 @@ export class API {
|
|||
return tok(gen(`${BASE}/image/preview/{fileid}`, { fileid }));
|
||||
}
|
||||
|
||||
static IMAGE_MULTIPREVIEW() {
|
||||
return tok(gen(`${BASE}/image/multipreview`));
|
||||
}
|
||||
|
||||
static IMAGE_INFO(id: number) {
|
||||
return tok(gen(`${BASE}/image/info/{id}`, { id }));
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue