Skip to content
Open
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
feb472b
feat: webm
khajiitvaper2017 Mar 2, 2026
e6422da
Refine media export and capture format settings
khajiitvaper2017 Mar 2, 2026
4f4c507
mini-preview rendering fix
khajiitvaper2017 Mar 3, 2026
a83ddd8
remove cap
khajiitvaper2017 Mar 3, 2026
1f2f08c
artifacts fixes
khajiitvaper2017 Mar 3, 2026
0f905d4
codecs
khajiitvaper2017 Mar 3, 2026
7a8cd89
refactor: remove redundant WebM codec tests and enhance logging for v…
khajiitvaper2017 Mar 3, 2026
9067b0b
Merge branch 'killergerbah:main' into feat-webm-v2
khajiitvaper2017 Mar 4, 2026
0c386a6
Improve WebM fragment rendering robustness
khajiitvaper2017 Mar 4, 2026
c113179
Refactor WebM renderer and log capture settings
khajiitvaper2017 Mar 4, 2026
16b5239
Stabilize WebM FPS sampling and log fallback paths
khajiitvaper2017 Mar 4, 2026
0fe7533
Merge branch 'feat-webm-v2' of https://github.com/khajiitvaper2017/as…
khajiitvaper2017 Mar 4, 2026
ba0f51f
Tune media fragment readiness timeout and cap WebM capture timeouts
khajiitvaper2017 Mar 4, 2026
81f552d
Avoid shared WebM render resources across timestamp-derived instances
khajiitvaper2017 Mar 4, 2026
3706a26
Harden WebM frame-rate sampling timeout and delta collection
khajiitvaper2017 Mar 4, 2026
34f2d08
Refactor WebM video state management with reusable VideoStateGuard
khajiitvaper2017 Mar 4, 2026
9d55355
Add explicit render reentry guard and simplify seek completion handling
khajiitvaper2017 Mar 4, 2026
ee9bf26
anki: abort updateLast export before media encoding when no note exists
khajiitvaper2017 Mar 4, 2026
55085e2
refactor(common): simplify WebM fragment rendering and lifecycle
khajiitvaper2017 Mar 4, 2026
ac38311
refactor(jpeg): remove async promise executor and make canvas render …
khajiitvaper2017 Mar 4, 2026
e6599f9
fix(jpeg): attach seek/error handlers before seeking and clean up lis…
khajiitvaper2017 Mar 4, 2026
4515399
refactor(jpeg): simplify base64/blob serialization async flow
khajiitvaper2017 Mar 4, 2026
80eb13e
test(jpeg): add targeted regression coverage for render retry, cancel…
khajiitvaper2017 Mar 4, 2026
8b63290
centered duration
khajiitvaper2017 Mar 4, 2026
50c6ffa
Merge branch 'main' into feat-webm-v2
khajiitvaper2017 Mar 6, 2026
82a6fe5
fix(media-fragment): stabilize WebM capture and simplify export flow
khajiitvaper2017 Mar 6, 2026
7adc4b1
hide webm settings in extension
khajiitvaper2017 Mar 6, 2026
de2cd44
localization
khajiitvaper2017 Mar 6, 2026
bb74d1b
Use slider for clip duration adjustment
khajiitvaper2017 Mar 7, 2026
3aabef1
Merge main into feat-webm-v2: resolve MiningSettingsTab conflicts
khajiitvaper2017 Mar 7, 2026
8e372d9
Removed unnecessary code + prettier
khajiitvaper2017 Mar 7, 2026
29e1329
Address PR review feedback
khajiitvaper2017 Mar 20, 2026
7279ca6
formatting
khajiitvaper2017 Mar 20, 2026
60bcf88
max clip length setting
khajiitvaper2017 Mar 21, 2026
9fec4c0
fix missing argument
khajiitvaper2017 Mar 21, 2026
dc19490
Restore seekable check
khajiitvaper2017 Mar 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 143 additions & 49 deletions common/anki/anki.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AudioClip } from '@project/common/audio-clip';
import { AnkiExportMode, CardModel, Image, Progress } from '@project/common';
import { AnkiExportMode, CardModel, MediaFragment, Progress } from '@project/common';
import { HttpFetcher, Fetcher } from '@project/common';
import { AnkiSettings, AnkiSettingsFieldKey } from '@project/common/settings';
import sanitize from 'sanitize-filename';
Expand All @@ -15,6 +15,22 @@ const alphaNumericCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuv
const unsafeURLChars = /[:\/\?#\[\]@!$&'()*+,;= "<>%{}|\\^`]/g;
const replacement = '_';

const logMediaCreationTime = (type: string, extension: string, durationMs: number, fileName: string) => {
console.info(`[asbplayer] ${type} creation took ${durationMs}ms (${fileName}, .${extension})`);
};

const timedMediaBase64 = async (
type: string,
extension: string,
fileName: string,
getBase64: () => Promise<string>
) => {
const startedAt = Date.now();
const data = await getBase64();
logMediaCreationTime(type, extension, Date.now() - startedAt, fileName);
return data;
};

export function escapeAnkiQuery(query: string) {
let escaped = '';

Expand Down Expand Up @@ -136,7 +152,7 @@ export interface ExportParams {
track3: string | undefined;
definition: string | undefined;
audioClip: AudioClip | undefined;
image: Image | undefined;
image: MediaFragment | undefined;
word: string | undefined;
source: string | undefined;
url: string | undefined;
Expand All @@ -146,6 +162,17 @@ export interface ExportParams {
ankiConnectUrl?: string;
}

interface EncodedMedia {
sanitizedName: string;
data: string;
}

interface Base64Exportable {
name: string;
extension: string;
base64: () => Promise<string>;
}

export async function exportCard(
card: CardModel,
ankiSettings: AnkiSettings,
Expand All @@ -166,6 +193,8 @@ export async function exportCard(
card.audio.error
);

const serializedMediaFragment = card.mediaFragment ?? card.image;

return await anki.export({
text: card.text ?? extractText(card.subtitle, card.surroundingSubtitles),
track1: extractText(card.subtitle, card.surroundingSubtitles, 0),
Expand All @@ -174,14 +203,14 @@ export async function exportCard(
definition: card.definition,
audioClip,
image:
card.image === undefined
serializedMediaFragment === undefined
? undefined
: Image.fromBase64(
: MediaFragment.fromBase64(
source,
card.subtitle.start,
card.image.base64,
card.image.extension,
card.image.error
serializedMediaFragment.base64,
serializedMediaFragment.extension,
serializedMediaFragment.error
),
word: card.word,
source: source,
Expand Down Expand Up @@ -476,40 +505,27 @@ export class Anki {
const gui = mode === 'gui';
const updateLast = mode === 'updateLast';

if (this.settingsProvider.audioField && audioClip && audioClip.error === undefined) {
const sanitizedName = this._sanitizeFileName(audioClip.name);
const data = await audioClip.base64();

if (data) {
if (gui || updateLast) {
const fileName = (await this._storeMediaFile(sanitizedName, data, ankiConnectUrl)).result;
this._appendField(fields, this.settingsProvider.audioField, `[sound:${fileName}]`, false);
} else {
params.note['audio'] = {
filename: sanitizedName,
data,
fields: [this.settingsProvider.audioField],
};
}
}
const recentNotes = updateLast ? await this.findNotes('added:1', ankiConnectUrl) : [];
if (updateLast && recentNotes.length === 0) {
throw new Error('Could not find note to update');
}

if (this.settingsProvider.imageField && image && image.error === undefined) {
const sanitizedName = this._sanitizeFileName(image.name);
const data = await image.base64();

if (data) {
if (gui || updateLast) {
const fileName = (await this._storeMediaFile(sanitizedName, data, ankiConnectUrl)).result;
this._appendField(fields, this.settingsProvider.imageField, `<img src="${fileName}">`, false);
} else {
params.note['picture'] = {
filename: sanitizedName,
data,
fields: [this.settingsProvider.imageField],
};
}
}
const exportableAudio =
this.settingsProvider.audioField && audioClip && audioClip.error === undefined ? audioClip : undefined;
const exportableImage =
this.settingsProvider.imageField && image && image.error === undefined ? image : undefined;

const [encodedAudio, encodedImage] = await Promise.all([
this._encodeMedia(exportableAudio, 'audio'),
this._encodeMedia(exportableImage, image?.extension === 'webm' ? 'clip' : 'image'),
]);

if (encodedAudio) {
await this._attachAudio(params, fields, encodedAudio, gui || updateLast, ankiConnectUrl);
}

if (encodedImage && image) {
await this._attachMediaFragment(params, fields, encodedImage, image, gui || updateLast, ankiConnectUrl);
}

params.note['fields'] = fields;
Expand All @@ -518,17 +534,9 @@ export class Anki {
case 'gui':
return (await this._executeAction('guiAddCards', params, ankiConnectUrl)).result;
case 'updateLast':
const recentNotes = (
await this._executeAction('findNotes', { query: 'added:1' }, ankiConnectUrl)
).result.sort();

if (recentNotes.length === 0) {
throw new Error('Could not find note to update');
}

const lastNoteId = recentNotes[recentNotes.length - 1];
const lastNoteId = [...recentNotes].sort()[recentNotes.length - 1];
params.note['id'] = lastNoteId;
const infoResponse = await this._executeAction('notesInfo', { notes: [lastNoteId] });
const infoResponse = await this._executeAction('notesInfo', { notes: [lastNoteId] }, ankiConnectUrl);

if (infoResponse.result.length > 0 && infoResponse.result[0].noteId === lastNoteId) {
const info = infoResponse.result[0];
Expand Down Expand Up @@ -587,6 +595,92 @@ export class Anki {
fields[fieldName] = newValue;
}

private async _encodeMedia(media: Base64Exportable | undefined, type: string): Promise<EncodedMedia | undefined> {
if (!media) {
return undefined;
}

const sanitizedName = this._sanitizeFileName(media.name);
const data = await timedMediaBase64(type, media.extension, sanitizedName, () => media.base64());

if (!data) {
return undefined;
}

return { sanitizedName, data };
}

private async _attachAudio(
params: any,
fields: any,
encodedAudio: EncodedMedia,
storeMediaFile: boolean,
ankiConnectUrl?: string
) {
if (storeMediaFile) {
await this._storeAndAppendField(
fields,
this.settingsProvider.audioField,
encodedAudio,
(fileName) => `[sound:${fileName}]`,
ankiConnectUrl
);
return;
}

params.note['audio'] = {
filename: encodedAudio.sanitizedName,
data: encodedAudio.data,
fields: [this.settingsProvider.audioField],
};
}

private async _attachMediaFragment(
params: any,
fields: any,
encodedImage: EncodedMedia,
image: MediaFragment,
storeMediaFile: boolean,
ankiConnectUrl?: string
) {
if (image.extension === 'webm' || storeMediaFile) {
await this._storeAndAppendField(
fields,
this.settingsProvider.imageField,
encodedImage,
(fileName) => this._mediaFragmentFieldHtml(fileName, image.extension),
ankiConnectUrl
);
return;
}

params.note['picture'] = {
filename: encodedImage.sanitizedName,
data: encodedImage.data,
fields: [this.settingsProvider.imageField],
};
}

private async _storeAndAppendField(
fields: any,
fieldName: string | undefined,
encodedMedia: EncodedMedia,
value: (fileName: string) => string,
ankiConnectUrl?: string
) {
const fileName = (await this._storeMediaFile(encodedMedia.sanitizedName, encodedMedia.data, ankiConnectUrl))
.result;
this._appendField(fields, fieldName, value(fileName), false);
}

private _mediaFragmentFieldHtml(fileName: string, extension: string) {
if (extension === 'webm') {
return `<video autoplay loop muted playsinline src="${fileName}"></video>`;
}

return `<img src="${fileName}">`;
}

private _sanitizeUnsafeURLChars(name: string) {
return name.replace(unsafeURLChars, replacement);
}
Expand Down
34 changes: 26 additions & 8 deletions common/app/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { type Theme } from '@mui/material/styles';
import ThemeProvider from '@mui/material/styles/ThemeProvider';
import { useWindowSize } from '../hooks/use-window-size';
import {
Image,
MediaFragment,
SubtitleModel,
VideoTabModel,
LegacyPlayerSyncMessage,
Expand All @@ -19,7 +19,7 @@ import {
DownloadImageMessage,
DownloadAudioMessage,
CardTextFieldValues,
ImageErrorCode,
MediaFragmentErrorCode,
RequestSubtitlesResponse,
} from '@project/common';
import { createTheme } from '@project/common/theme';
Expand Down Expand Up @@ -506,10 +506,13 @@ function App({
track3: extractText(card.subtitle, card.surroundingSubtitles, 2),
definition: newCard.definition ?? '',
audioClip: audioClip,
image: Image.fromCard(
image: MediaFragment.fromCard(
newCard,
settingsRef.current.maxImageWidth,
settingsRef.current.maxImageHeight
settingsRef.current.maxImageHeight,
settingsRef.current.mediaFragmentFormat,
settingsRef.current.mediaFragmentTrimStart,
settingsRef.current.mediaFragmentTrimEnd
),
word: newCard.word ?? '',
source: `${newCard.subtitleFileName} (${humanReadableTime(card.mediaTimestamp)})`,
Expand Down Expand Up @@ -626,20 +629,35 @@ function App({
const handleDownloadImage = useCallback(
(item: CardModel) => {
try {
const image = Image.fromCard(item, settings.maxImageWidth, settings.maxImageHeight)!;
const image = MediaFragment.fromCard(
item,
settings.maxImageWidth,
settings.maxImageHeight,
settings.mediaFragmentFormat,
settings.mediaFragmentTrimStart,
settings.mediaFragmentTrimEnd
)!;

if (image.error === undefined) {
image.download();
} else if (image.error === ImageErrorCode.fileLinkLost) {
} else if (image.error === MediaFragmentErrorCode.fileLinkLost) {
handleError(t('ankiDialog.imageFileLinkLost'));
} else if (image.error === ImageErrorCode.captureFailed) {
} else if (image.error === MediaFragmentErrorCode.captureFailed) {
handleError(t('ankiDialog.imageCaptureFailed'));
}
} catch (e) {
handleError(e);
}
},
[handleError, settings.maxImageWidth, settings.maxImageHeight, t]
[
handleError,
settings.maxImageWidth,
settings.maxImageHeight,
settings.mediaFragmentFormat,
settings.mediaFragmentTrimStart,
settings.mediaFragmentTrimEnd,
t,
]
);

const handleDownloadCopyHistorySectionAsSrt = useCallback(
Expand Down
4 changes: 2 additions & 2 deletions common/app/components/CopyHistoryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import Typography from '@mui/material/Typography';
import { type Theme } from '@mui/material';
import { CopyHistoryItem } from '../..';
import { AudioClip } from '../../audio-clip';
import { Image } from '../..';
import { MediaFragment } from '../..';

interface CopyHistoryListProps {
open: boolean;
Expand Down Expand Up @@ -107,7 +107,7 @@ const useImageAvailability = (item: CopyHistoryItem) => {
const [isImageAvailable, setIsImageAvailable] = useState<boolean>();

useEffect(() => {
const image = Image.fromCard(item, 0, 0);
const image = MediaFragment.fromCard(item, 0, 0);

if (image) {
setIsImageAvailable(image.error === undefined);
Expand Down
9 changes: 9 additions & 0 deletions common/app/hooks/use-anki.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ class SettingsAccessor {
get maxImageHeight() {
return this.settings.maxImageHeight;
}
get mediaFragmentFormat() {
return this.settings.mediaFragmentFormat;
}
get mediaFragmentTrimStart() {
return this.settings.mediaFragmentTrimStart;
}
get mediaFragmentTrimEnd() {
return this.settings.mediaFragmentTrimEnd;
}
get surroundingSubtitlesCountRadius() {
return this.settings.surroundingSubtitlesCountRadius;
}
Expand Down
6 changes: 6 additions & 0 deletions common/app/services/video-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,9 @@ export default class VideoChannel {
audioPaddingEnd,
maxImageWidth,
maxImageHeight,
mediaFragmentFormat,
mediaFragmentTrimStart,
mediaFragmentTrimEnd,
surroundingSubtitlesCountRadius,
surroundingSubtitlesTimeRadius,
ankiFieldSettings,
Expand Down Expand Up @@ -618,6 +621,9 @@ export default class VideoChannel {
audioPaddingEnd,
maxImageWidth,
maxImageHeight,
mediaFragmentFormat,
mediaFragmentTrimStart,
mediaFragmentTrimEnd,
surroundingSubtitlesCountRadius,
surroundingSubtitlesTimeRadius,
ankiFieldSettings,
Expand Down
Loading