Improve importing resources (#1120)

* compress video before import

* Fix UI

* compress audio before importing

* fix

* save recording as mp3 to save disk space
This commit is contained in:
an-lee
2024-10-14 11:23:06 +08:00
committed by GitHub
parent 6392b261fc
commit dd7f932516
17 changed files with 182 additions and 82 deletions

View File

@@ -309,7 +309,9 @@
"welcomeBack": "Welcome back! {{name}}",
"print": "Print",
"download": "Download",
"downloading": "Downloading {{file}}",
"downloadingFile": "Downloading {{file}}",
"downloading": "Downloading",
"importing": "Importing",
"downloadedSuccessfully": "Downloaded successfully",
"downloadFailed": "Download failed",
"chooseAIModelDependingOnYourHardware": "Choose AI Model depending on your hardware.",

View File

@@ -309,7 +309,9 @@
"welcomeBack": "欢迎回来, {{name}}",
"print": "打印",
"download": "下载",
"downloading": "正在下载 {{file}}",
"downloadingFile": "正在下载 {{file}}",
"downloading": "正在下载",
"importing": "正在导入",
"downloadedSuccessfully": "下载成功",
"downloadFailed": "下载失败",
"chooseAIModelDependingOnYourHardware": "根据您的硬件选择合适的 AI 模型, 以便语音转文本服务正常工作",

View File

@@ -2,7 +2,6 @@ import {
AfterCreate,
AfterUpdate,
AfterDestroy,
BeforeCreate,
BelongsTo,
Table,
Column,
@@ -36,8 +35,6 @@ import startCase from "lodash/startCase";
import { v5 as uuidv5 } from "uuid";
import FfmpegWrapper from "@main/ffmpeg";
const SIZE_LIMIT = 1024 * 1024 * 50; // 50MB
const logger = log.scope("db/models/audio");
@Table({
@@ -127,7 +124,13 @@ export class Audio extends Model<Audio> {
@Column(DataType.VIRTUAL)
get src(): string {
if (this.filePath) {
if (this.compressedFilePath) {
return `enjoy://${path.posix.join(
"library",
"audios",
this.getDataValue("md5") + ".compressed.mp3"
)}`;
} else if (this.originalFilePath) {
return `enjoy://${path.posix.join(
"library",
"audios",
@@ -162,6 +165,10 @@ export class Audio extends Model<Audio> {
}
get filePath(): string {
return this.compressedFilePath || this.originalFilePath;
}
get originalFilePath(): string {
const file = path.join(
settings.userDataPath(),
"audios",
@@ -175,6 +182,20 @@ export class Audio extends Model<Audio> {
}
}
get compressedFilePath(): string {
const file = path.join(
settings.userDataPath(),
"audios",
this.getDataValue("md5") + ".compressed.mp3"
);
if (fs.existsSync(file)) {
return file;
} else {
return null;
}
}
async upload(force: boolean = false) {
if (this.isUploaded && !force) return;
@@ -226,20 +247,6 @@ export class Audio extends Model<Audio> {
return output;
}
@BeforeCreate
static async setupDefaultAttributes(audio: Audio) {
try {
const ffmpeg = new Ffmpeg();
const fileMetadata = await ffmpeg.generateMetadata(audio.filePath);
audio.metadata = Object.assign(audio.metadata || {}, {
...fileMetadata,
duration: fileMetadata.format.duration,
});
} catch (err) {
logger.error("failed to generate metadata", err.message);
}
}
@AfterCreate
static autoSync(audio: Audio) {
// auto sync should not block the main thread
@@ -315,11 +322,6 @@ export class Audio extends Model<Audio> {
throw new Error(t("models.audio.fileNotSupported", { file: filePath }));
}
const stats = fs.statSync(filePath);
if (stats.size > SIZE_LIMIT) {
throw new Error(t("models.audio.fileTooLarge", { file: filePath }));
}
const md5 = await hashFile(filePath, { algo: "md5" });
// check if file already exists
@@ -338,15 +340,27 @@ export class Audio extends Model<Audio> {
logger.debug("Generated ID:", id);
const destDir = path.join(settings.userDataPath(), "audios");
const destFile = path.join(destDir, `${md5}${extname}`);
const destFile = path.join(destDir, `${md5}.compressed.mp3`);
let metadata = {
extname,
};
// Copy file to library
try {
// Create directory if not exists
fs.ensureDirSync(destDir);
// Copy file
fs.copySync(filePath, destFile);
// Generate metadata
const ffmpeg = new Ffmpeg();
const fileMetadata = await ffmpeg.generateMetadata(filePath);
metadata = Object.assign(metadata, {
...fileMetadata,
duration: fileMetadata.format.duration,
});
// Compress file
await ffmpeg.compressAudio(filePath, destFile);
// Check if file copied
fs.accessSync(destFile, fs.constants.R_OK);
@@ -367,9 +381,7 @@ export class Audio extends Model<Audio> {
name,
description,
coverUrl,
metadata: {
extname,
},
metadata,
});
return record.save().catch((err) => {

View File

@@ -32,6 +32,7 @@ import echogarden from "@main/echogarden";
import { t } from "i18next";
import { Attributes, Transaction } from "sequelize";
import { v5 as uuidv5 } from "uuid";
import FfmpegWrapper from "@main/ffmpeg";
const logger = log.scope("db/models/recording");
@@ -299,14 +300,10 @@ export class Recording extends Model<Recording> {
}
// rename file
const filename = `${md5}.wav`;
fs.moveSync(
file,
path.join(settings.userDataPath(), "recordings", filename),
{
overwrite: true,
}
);
const filename = `${md5}.mp3`;
const destFile = path.join(settings.userDataPath(), "recordings", filename);
const ffmpeg = new FfmpegWrapper();
await ffmpeg.compressAudio(file, destFile);
const userId = settings.getSync("user.id");
const id = uuidv5(`${userId}/${md5}`, uuidv5.URL);

View File

@@ -2,7 +2,6 @@ import {
AfterCreate,
AfterUpdate,
AfterDestroy,
BeforeCreate,
BelongsTo,
Table,
Column,
@@ -36,8 +35,6 @@ import startCase from "lodash/startCase";
import { v5 as uuidv5 } from "uuid";
import FfmpegWrapper from "@main/ffmpeg";
const SIZE_LIMIT = 1024 * 1024 * 100; // 100MB
const logger = log.scope("db/models/video");
@Table({
@@ -127,7 +124,13 @@ export class Video extends Model<Video> {
@Column(DataType.VIRTUAL)
get src(): string {
if (this.filePath) {
if (this.compressedFilePath) {
return `enjoy://${path.posix.join(
"library",
"videos",
this.getDataValue("md5") + ".compressed.mp4"
)}`;
} else if (this.originalFilePath) {
return `enjoy://${path.posix.join(
"library",
"videos",
@@ -162,6 +165,10 @@ export class Video extends Model<Video> {
}
get filePath(): string {
return this.compressedFilePath || this.originalFilePath;
}
get originalFilePath(): string {
const file = path.join(
settings.userDataPath(),
"videos",
@@ -175,6 +182,20 @@ export class Video extends Model<Video> {
}
}
get compressedFilePath(): string {
const file = path.join(
settings.userDataPath(),
"videos",
`${this.getDataValue("md5")}.compressed.mp4`
);
if (fs.existsSync(file)) {
return file;
} else {
return null;
}
}
// generate cover and upload
async generateCover() {
if (this.coverUrl) return;
@@ -247,20 +268,6 @@ export class Video extends Model<Video> {
return output;
}
@BeforeCreate
static async setupDefaultAttributes(video: Video) {
try {
const ffmpeg = new Ffmpeg();
const fileMetadata = await ffmpeg.generateMetadata(video.filePath);
video.metadata = Object.assign(video.metadata || {}, {
...fileMetadata,
duration: fileMetadata.format.duration,
});
} catch (err) {
logger.error("failed to generate metadata", err.message);
}
}
@AfterCreate
static autoSync(video: Video) {
// auto sync should not block the main thread
@@ -331,11 +338,6 @@ export class Video extends Model<Video> {
throw new Error(t("models.video.fileNotSupported", { file: filePath }));
}
const stats = fs.statSync(filePath);
if (stats.size > SIZE_LIMIT) {
throw new Error(t("models.video.fileTooLarge", { file: filePath }));
}
const md5 = await hashFile(filePath, { algo: "md5" });
// check if file already exists
@@ -354,15 +356,27 @@ export class Video extends Model<Video> {
logger.debug("Generated ID:", id);
const destDir = path.join(settings.userDataPath(), "videos");
const destFile = path.join(destDir, `${md5}${extname}`);
const destFile = path.join(destDir, `${md5}.compressed.mp4`);
let metadata = {
extname,
};
// Copy file to library
try {
// Create directory if not exists
fs.ensureDirSync(destDir);
// Copy file
fs.copySync(filePath, destFile);
// fetch metadata
const ffmpeg = new FfmpegWrapper();
const fileMetadata = await ffmpeg.generateMetadata(filePath);
metadata = Object.assign(metadata, {
...fileMetadata,
duration: fileMetadata.format.duration,
});
// Compress file to destFile
await ffmpeg.compressVideo(filePath, destFile);
// Check if file copied
fs.accessSync(destFile, fs.constants.R_OK);
@@ -383,9 +397,7 @@ export class Video extends Model<Video> {
name,
description,
coverUrl,
metadata: {
extname,
},
metadata,
});
return record.save().catch((err) => {

View File

@@ -3,6 +3,7 @@ import path from "path";
import fs from "fs";
import mainWin from "@main/window";
import log from "@main/logger";
import settings from "@main/settings";
const logger = log.scope("downloader");
class Downloader {
@@ -24,6 +25,7 @@ class Downloader {
return new Promise((resolve, _reject) => {
webContents.downloadURL(url);
const cachePath = settings.cachePath();
webContents.session.on("will-download", (_event, item, _webContents) => {
if (savePath) {
try {
@@ -36,9 +38,7 @@ class Downloader {
item.setSavePath(savePath);
}
} else {
item.setSavePath(
path.join(app.getPath("downloads"), item.getFilename())
);
item.setSavePath(path.join(cachePath, item.getFilename()));
}
this.tasks.push(item);

View File

@@ -290,6 +290,70 @@ export default class FfmpegWrapper {
});
}
compressVideo(input: string, output: string) {
const ffmpeg = Ffmpeg();
return new Promise((resolve, reject) => {
ffmpeg
.input(input)
.outputOptions(
"-c:v",
"libx264",
"-tag:v",
"avc1",
"-movflags",
"faststart",
"-crf",
"30",
"-preset",
"superfast"
)
.on("start", (commandLine) => {
logger.info("Spawned FFmpeg with command: " + commandLine);
fs.ensureDirSync(path.dirname(output));
})
.on("end", () => {
logger.info(`File "${output}" created`);
resolve(output);
})
.on("error", (err) => {
logger.error(err);
reject(err);
})
.save(output);
});
}
compressAudio(input: string, output: string) {
const ffmpeg = Ffmpeg();
return new Promise((resolve, reject) => {
ffmpeg
.input(input)
.outputOptions(
"-ar",
"16000",
"-b:a",
"32000",
"-ac",
"1",
"-preset",
"superfast"
)
.on("start", (commandLine) => {
logger.info("Spawned FFmpeg with command: " + commandLine);
fs.ensureDirSync(path.dirname(output));
})
.on("end", () => {
logger.info(`File "${output}" created`);
resolve(output);
})
.on("error", (err) => {
logger.error(err);
reject(err);
})
.save(output);
});
}
registerIpcHandlers() {
ipcMain.handle("ffmpeg-check-command", async (_event) => {
return await this.checkCommand();

View File

@@ -175,7 +175,11 @@ export const AudibleBooksSegment = () => {
{downloading && (
<LoaderIcon className="w-4 h-4 animate-spin mr-2" />
)}
{t("downloadSample")}
{downloading
? progress < 100
? t("downloading")
: t("importing")
: t("downloadSample")}
</Button>
</DialogFooter>

View File

@@ -276,7 +276,9 @@ const ChatUserMessageActions = (props: {
savePath as string
),
{
loading: t("downloading", { file: chatMessage.recording.filename }),
loading: t("downloadingFile", {
file: chatMessage.recording.filename,
}),
success: () => t("downloadedSuccessfully"),
error: t("downloadFailed"),
position: "bottom-right",

View File

@@ -251,7 +251,7 @@ export const MediaCurrentRecording = () => {
toast.promise(
EnjoyApp.download.start(currentRecording.src, savePath as string),
{
loading: t("downloading", { file: currentRecording.filename }),
loading: t("downloadingFile", { file: currentRecording.filename }),
success: () => t("downloadedSuccessfully"),
error: t("downloadFailed"),
position: "bottom-right",

View File

@@ -122,7 +122,7 @@ export const MediaWaveform = () => {
if (!savePath) return;
toast.promise(EnjoyApp.download.start(media.src, savePath as string), {
loading: t("downloading", { file: media.filename }),
loading: t("downloadingFile", { file: media.filename }),
success: () => t("downloadedSuccessfully"),
error: t("downloadFailed"),
position: "bottom-right",

View File

@@ -78,7 +78,7 @@ export const MediaRecordings = () => {
if (!savePath) return;
toast.promise(EnjoyApp.download.start(url, savePath as string), {
loading: t("downloading", { file: filename }),
loading: t("downloadingFile", { file: filename }),
success: () => t("downloadedSuccessfully"),
error: t("downloadFailed"),
position: "bottom-right",

View File

@@ -145,7 +145,7 @@ const TranscriptionRecordingsList = () => {
toast.promise(
EnjoyApp.download.start(recording.src, savePath as string),
{
loading: t("downloading", { file: recording.filename }),
loading: t("downloadingFile", { file: recording.filename }),
success: () => t("downloadedSuccessfully"),
error: t("downloadFailed"),
position: "bottom-right",

View File

@@ -80,7 +80,7 @@ export const MediaCaptionActions = (props: {
toast.promise(
EnjoyApp.download.start(segment.src, savePath as string),
{
loading: t("downloading", { file: media.filename }),
loading: t("downloadingFile", { file: media.filename }),
success: () => t("downloadedSuccessfully"),
error: t("downloadFailed"),
position: "bottom-right",
@@ -133,7 +133,7 @@ export const MediaCaptionActions = (props: {
if (!savePath) return;
toast.promise(EnjoyApp.download.start(src, savePath as string), {
loading: t("downloading", { file: media.filename }),
loading: t("downloadingFile", { file: media.filename }),
success: () => t("downloadedSuccessfully"),
error: t("downloadFailed"),
position: "bottom-right",

View File

@@ -142,7 +142,7 @@ export const AssistantMessageComponent = (props: {
if (!savePath) return;
toast.promise(EnjoyApp.download.start(speech.src, savePath as string), {
loading: t("downloading", { file: speech.filename }),
loading: t("downloadingFile", { file: speech.filename }),
success: () => t("downloadedSuccessfully"),
error: t("downloadFailed"),
position: "bottom-right",

View File

@@ -161,7 +161,11 @@ export const YoutubeVideosSegment = (props: { channel: string }) => {
{submitting && (
<LoaderIcon className="w-4 h-4 animate-spin mr-2" />
)}
{t("downloadVideo")}
{submitting
? progress < 100
? t("downloading")
: t("importing")
: t("downloadVideo")}
</Button>
</DialogFooter>
{submitting && (

View File

@@ -19,7 +19,8 @@ export const usePronunciationAssessments = () => {
}
EnjoyApp.recordings.sync(recording.id);
const blob = await (await fetch(recording.src)).blob();
const url = await EnjoyApp.echogarden.transcode(recording.src);
const blob = await (await fetch(url)).blob();
targetId = recording.id;
targetType = "Recording";