feat(frontend): EXIFフレーム機能 (#16725)
* wip
* wip
* Update ImageEffector.ts
* Update image-label-renderer.ts
* Update image-label-renderer.ts
* wip
* Update image-label-renderer.ts
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Update use-uploader.ts
* Update watermark.ts
* wip
* wu
* wip
* Update image-frame-renderer.ts
* wip
* wip
* Update image-frame-renderer.ts
* Create ImageCompositor.ts
* Update ImageCompositor.ts
* wip
* wip
* Update ImageEffector.ts
* wip
* Update use-uploader.ts
* wip
* wip
* wip
* wip
* Update fxs.ts
* wip
* wip
* wip
* Update CHANGELOG.md
* wip
* wip
* Update MkImageEffectorDialog.vue
* Update MkImageEffectorDialog.vue
* Update MkImageFrameEditorDialog.vue
* Update use-uploader.ts
* improve error handling
* Update use-uploader.ts
* 🎨
* wip
* wip
* lazy load
* lazy load
* wip
* wip
* wip
This commit is contained in:
parent
26c8914a26
commit
4ba18690d7
|
|
@ -5,6 +5,8 @@
|
|||
- Enhance: DockerのNode.jsが24.10.0に更新されました
|
||||
|
||||
### Client
|
||||
- Feat: 画像にメタデータを含むフレームをつけられる機能
|
||||
- Enhance: プリセットを作成しなくても画像にウォーターマークを付与できるように
|
||||
- Enhance: 管理しているチャンネルの見分けがつきやすくなるように
|
||||
- Enhance: プロフィールへのリンクをユーザーポップアップのアバターに追加
|
||||
- Enhance: ユーザーのノート、フォロー、フォロワーページへのリンクをユーザーポップアップに追加
|
||||
|
|
|
|||
|
|
@ -5625,6 +5625,172 @@ export interface Locale extends ILocale {
|
|||
* あなたは管理者です
|
||||
*/
|
||||
"youAreAdmin": string;
|
||||
/**
|
||||
* フレーム
|
||||
*/
|
||||
"frame": string;
|
||||
/**
|
||||
* プリセット
|
||||
*/
|
||||
"presets": string;
|
||||
/**
|
||||
* ゼロ埋め
|
||||
*/
|
||||
"zeroPadding": string;
|
||||
"_imageEditing": {
|
||||
"_vars": {
|
||||
/**
|
||||
* ファイルのキャプション
|
||||
*/
|
||||
"caption": string;
|
||||
/**
|
||||
* ファイル名
|
||||
*/
|
||||
"filename": string;
|
||||
/**
|
||||
* 拡張子無しファイル名
|
||||
*/
|
||||
"filename_without_ext": string;
|
||||
/**
|
||||
* 撮影年
|
||||
*/
|
||||
"year": string;
|
||||
/**
|
||||
* 撮影月
|
||||
*/
|
||||
"month": string;
|
||||
/**
|
||||
* 撮影日
|
||||
*/
|
||||
"day": string;
|
||||
/**
|
||||
* 撮影した時刻(時)
|
||||
*/
|
||||
"hour": string;
|
||||
/**
|
||||
* 撮影した時刻(分)
|
||||
*/
|
||||
"minute": string;
|
||||
/**
|
||||
* 撮影した時刻(秒)
|
||||
*/
|
||||
"second": string;
|
||||
/**
|
||||
* カメラ名
|
||||
*/
|
||||
"camera_model": string;
|
||||
/**
|
||||
* レンズ名
|
||||
*/
|
||||
"camera_lens_model": string;
|
||||
/**
|
||||
* 焦点距離
|
||||
*/
|
||||
"camera_mm": string;
|
||||
/**
|
||||
* 焦点距離(35mm判換算)
|
||||
*/
|
||||
"camera_mm_35": string;
|
||||
/**
|
||||
* 絞り
|
||||
*/
|
||||
"camera_f": string;
|
||||
/**
|
||||
* シャッタースピード
|
||||
*/
|
||||
"camera_s": string;
|
||||
/**
|
||||
* ISO感度
|
||||
*/
|
||||
"camera_iso": string;
|
||||
/**
|
||||
* 緯度
|
||||
*/
|
||||
"gps_lat": string;
|
||||
/**
|
||||
* 経度
|
||||
*/
|
||||
"gps_long": string;
|
||||
};
|
||||
};
|
||||
"_imageFrameEditor": {
|
||||
/**
|
||||
* フレームの編集
|
||||
*/
|
||||
"title": string;
|
||||
/**
|
||||
* 画像にフレームやメタデータを含んだラベルを追加して装飾できます。
|
||||
*/
|
||||
"tip": string;
|
||||
/**
|
||||
* ヘッダー
|
||||
*/
|
||||
"header": string;
|
||||
/**
|
||||
* フッター
|
||||
*/
|
||||
"footer": string;
|
||||
/**
|
||||
* フチの幅
|
||||
*/
|
||||
"borderThickness": string;
|
||||
/**
|
||||
* ラベルの幅
|
||||
*/
|
||||
"labelThickness": string;
|
||||
/**
|
||||
* ラベルのスケール
|
||||
*/
|
||||
"labelScale": string;
|
||||
/**
|
||||
* 中央揃え
|
||||
*/
|
||||
"centered": string;
|
||||
/**
|
||||
* キャプション(大)
|
||||
*/
|
||||
"captionMain": string;
|
||||
/**
|
||||
* キャプション(小)
|
||||
*/
|
||||
"captionSub": string;
|
||||
/**
|
||||
* 利用可能な変数
|
||||
*/
|
||||
"availableVariables": string;
|
||||
/**
|
||||
* 二次元コード
|
||||
*/
|
||||
"withQrCode": string;
|
||||
/**
|
||||
* 背景色
|
||||
*/
|
||||
"backgroundColor": string;
|
||||
/**
|
||||
* 文字色
|
||||
*/
|
||||
"textColor": string;
|
||||
/**
|
||||
* フォント
|
||||
*/
|
||||
"font": string;
|
||||
/**
|
||||
* セリフ
|
||||
*/
|
||||
"fontSerif": string;
|
||||
/**
|
||||
* サンセリフ
|
||||
*/
|
||||
"fontSansSerif": string;
|
||||
/**
|
||||
* 保存せずに終了しますか?
|
||||
*/
|
||||
"quitWithoutSaveConfirm": string;
|
||||
/**
|
||||
* 画像の読み込みに失敗しました
|
||||
*/
|
||||
"failedToLoadImage": string;
|
||||
};
|
||||
"_compression": {
|
||||
"_quality": {
|
||||
/**
|
||||
|
|
@ -12354,7 +12520,7 @@ export interface Locale extends ILocale {
|
|||
"defaultPreset": string;
|
||||
"_watermarkEditor": {
|
||||
/**
|
||||
* 画像にクレジット情報などのウォーターマークを追加することができます。
|
||||
* 画像にクレジット情報などのウォーターマークを追加できます。
|
||||
*/
|
||||
"tip": string;
|
||||
/**
|
||||
|
|
@ -12469,6 +12635,10 @@ export interface Locale extends ILocale {
|
|||
* 空欄にするとアカウントのURLになります
|
||||
*/
|
||||
"leaveBlankToAccountUrl": string;
|
||||
/**
|
||||
* 画像の読み込みに失敗しました
|
||||
*/
|
||||
"failedToLoadImage": string;
|
||||
};
|
||||
"_imageEffector": {
|
||||
/**
|
||||
|
|
@ -12487,6 +12657,10 @@ export interface Locale extends ILocale {
|
|||
* 設定項目はありません
|
||||
*/
|
||||
"nothingToConfigure": string;
|
||||
/**
|
||||
* 画像の読み込みに失敗しました
|
||||
*/
|
||||
"failedToLoadImage": string;
|
||||
"_fxs": {
|
||||
/**
|
||||
* 色収差
|
||||
|
|
|
|||
|
|
@ -1401,6 +1401,51 @@ widgets: "ウィジェット"
|
|||
deviceInfo: "デバイス情報"
|
||||
deviceInfoDescription: "技術的なお問い合わせの際に、以下の情報を併記すると問題の解決に役立つことがあります。"
|
||||
youAreAdmin: "あなたは管理者です"
|
||||
frame: "フレーム"
|
||||
presets: "プリセット"
|
||||
zeroPadding: "ゼロ埋め"
|
||||
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "ファイルのキャプション"
|
||||
filename: "ファイル名"
|
||||
filename_without_ext: "拡張子無しファイル名"
|
||||
year: "撮影年"
|
||||
month: "撮影月"
|
||||
day: "撮影日"
|
||||
hour: "撮影した時刻(時)"
|
||||
minute: "撮影した時刻(分)"
|
||||
second: "撮影した時刻(秒)"
|
||||
camera_model: "カメラ名"
|
||||
camera_lens_model: "レンズ名"
|
||||
camera_mm: "焦点距離"
|
||||
camera_mm_35: "焦点距離(35mm判換算)"
|
||||
camera_f: "絞り"
|
||||
camera_s: "シャッタースピード"
|
||||
camera_iso: "ISO感度"
|
||||
gps_lat: "緯度"
|
||||
gps_long: "経度"
|
||||
|
||||
_imageFrameEditor:
|
||||
title: "フレームの編集"
|
||||
tip: "画像にフレームやメタデータを含んだラベルを追加して装飾できます。"
|
||||
header: "ヘッダー"
|
||||
footer: "フッター"
|
||||
borderThickness: "フチの幅"
|
||||
labelThickness: "ラベルの幅"
|
||||
labelScale: "ラベルのスケール"
|
||||
centered: "中央揃え"
|
||||
captionMain: "キャプション(大)"
|
||||
captionSub: "キャプション(小)"
|
||||
availableVariables: "利用可能な変数"
|
||||
withQrCode: "二次元コード"
|
||||
backgroundColor: "背景色"
|
||||
textColor: "文字色"
|
||||
font: "フォント"
|
||||
fontSerif: "セリフ"
|
||||
fontSansSerif: "サンセリフ"
|
||||
quitWithoutSaveConfirm: "保存せずに終了しますか?"
|
||||
failedToLoadImage: "画像の読み込みに失敗しました"
|
||||
|
||||
_compression:
|
||||
_quality:
|
||||
|
|
@ -3307,7 +3352,7 @@ _userLists:
|
|||
watermark: "ウォーターマーク"
|
||||
defaultPreset: "デフォルトのプリセット"
|
||||
_watermarkEditor:
|
||||
tip: "画像にクレジット情報などのウォーターマークを追加することができます。"
|
||||
tip: "画像にクレジット情報などのウォーターマークを追加できます。"
|
||||
quitWithoutSaveConfirm: "保存せずに終了しますか?"
|
||||
driveFileTypeWarn: "このファイルは対応していません"
|
||||
driveFileTypeWarnDescription: "画像ファイルを選択してください"
|
||||
|
|
@ -3336,12 +3381,14 @@ _watermarkEditor:
|
|||
polkadotSubDotRadius: "サブドットの大きさ"
|
||||
polkadotSubDotDivisions: "サブドットの数"
|
||||
leaveBlankToAccountUrl: "空欄にするとアカウントのURLになります"
|
||||
failedToLoadImage: "画像の読み込みに失敗しました"
|
||||
|
||||
_imageEffector:
|
||||
title: "エフェクト"
|
||||
addEffect: "エフェクトを追加"
|
||||
discardChangesConfirm: "変更を破棄して終了しますか?"
|
||||
nothingToConfigure: "設定項目はありません"
|
||||
failedToLoadImage: "画像の読み込みに失敗しました"
|
||||
|
||||
_fxs:
|
||||
chromaticAberration: "色収差"
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@
|
|||
"estree-walker": "3.0.3",
|
||||
"eventemitter3": "5.0.1",
|
||||
"execa": "9.6.0",
|
||||
"exifreader": "4.32.0",
|
||||
"frontend-shared": "workspace:*",
|
||||
"icons-subsetter": "workspace:*",
|
||||
"idb-keyval": "6.2.2",
|
||||
|
|
|
|||
|
|
@ -24,9 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:leaveToClass="$style.transition_x_leaveTo"
|
||||
>
|
||||
<div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot">
|
||||
<div
|
||||
:class="$style.embedCodeGenPreviewRoot"
|
||||
>
|
||||
<div :class="[$style.embedCodeGenPreviewRoot, prefer.s.animation ? $style.animatedBg : null]">
|
||||
<MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/>
|
||||
<div :class="$style.embedCodeGenPreviewWrapper">
|
||||
<div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div>
|
||||
|
|
@ -91,20 +89,18 @@ import { url } from '@@/js/config.js';
|
|||
import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
|
||||
import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
||||
import MkCode from '@/components/MkCode.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { useMkSelect } from '@/composables/use-mkselect.js';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
||||
import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'ok'): void;
|
||||
|
|
@ -314,10 +310,19 @@ onUnmounted(() => {
|
|||
|
||||
.embedCodeGenPreviewRoot {
|
||||
position: relative;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-size: auto auto;
|
||||
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px);
|
||||
cursor: not-allowed;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.animatedBg {
|
||||
animation: bg 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bg {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: -20px -20px; }
|
||||
}
|
||||
|
||||
.embedCodeGenPreviewWrapper {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<MkFolder :defaultOpen="true" :canPage="false">
|
||||
<template #label>{{ fx.name }}</template>
|
||||
<template #label>{{ fx.uiDefinition.name }}</template>
|
||||
<template #footer>
|
||||
<div class="_buttons">
|
||||
<MkButton iconOnly @click="emit('del')"><i class="ti ti-trash"></i></MkButton>
|
||||
|
|
@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<MkImageEffectorFxForm v-model="layer.params" :paramDefs="fx.params" />
|
||||
<MkImageEffectorFxForm v-model="layer.params" :paramDefs="fx.uiDefinition.params"/>
|
||||
</MkFolder>
|
||||
</template>
|
||||
|
||||
|
|
@ -26,14 +26,14 @@ import MkImageEffectorFxForm from '@/components/MkImageEffectorFxForm.vue';
|
|||
import { FXS } from '@/utility/image-effector/fxs.js';
|
||||
|
||||
const layer = defineModel<ImageEffectorLayer>('layer', { required: true });
|
||||
const fx = FXS.find((fx) => fx.id === layer.value.fxId);
|
||||
const fx = FXS[layer.value.fxId];
|
||||
if (fx == null) {
|
||||
throw new Error(`Unrecognized effect: ${layer.value.fxId}`);
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'del'): void;
|
||||
(e: 'swapUp'): void;
|
||||
(e: 'swapDown'): void;
|
||||
(ev: 'del'): void;
|
||||
(ev: 'swapUp'): void;
|
||||
(ev: 'swapDown'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.preview">
|
||||
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
|
||||
<canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown.prevent.stop="onImagePointerdown"></canvas>
|
||||
<div :class="$style.previewContainer">
|
||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
||||
|
|
@ -64,6 +64,7 @@ import * as os from '@/os.js';
|
|||
import { deepClone } from '@/utility/clone.js';
|
||||
import { FXS } from '@/utility/image-effector/fxs.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = defineProps<{
|
||||
image: File;
|
||||
|
|
@ -94,19 +95,19 @@ const layers = reactive<ImageEffectorLayer[]>([]);
|
|||
|
||||
watch(layers, async () => {
|
||||
if (renderer != null) {
|
||||
renderer.setLayers(layers);
|
||||
renderer.render(layers);
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
function addEffect(ev: MouseEvent) {
|
||||
os.popupMenu(FXS.map((fx) => ({
|
||||
text: fx.name,
|
||||
os.popupMenu(Object.entries(FXS).map(([id, fx]) => ({
|
||||
text: fx.uiDefinition.name,
|
||||
action: () => {
|
||||
layers.push({
|
||||
id: genId(),
|
||||
fxId: fx.id,
|
||||
params: Object.fromEntries(Object.entries(fx.params).map(([k, v]) => [k, v.default])),
|
||||
});
|
||||
fxId: id as keyof typeof FXS,
|
||||
params: Object.fromEntries(Object.entries(fx.uiDefinition.params).map(([k, v]) => [k, v.default])),
|
||||
} as ImageEffectorLayer);
|
||||
},
|
||||
})), ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
|
@ -136,7 +137,7 @@ function onLayerDelete(layer: ImageEffectorLayer) {
|
|||
|
||||
const canvasEl = useTemplateRef('canvasEl');
|
||||
|
||||
let renderer: ImageEffector<typeof FXS> | null = null;
|
||||
let renderer: ImageEffector | null = null;
|
||||
let imageBitmap: ImageBitmap | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
|
|
@ -146,31 +147,36 @@ onMounted(async () => {
|
|||
|
||||
await nextTick(); // waitingがレンダリングされるまで待つ
|
||||
|
||||
imageBitmap = await window.createImageBitmap(props.image);
|
||||
try {
|
||||
imageBitmap = await window.createImageBitmap(props.image);
|
||||
|
||||
const MAX_W = 1000;
|
||||
const MAX_H = 1000;
|
||||
let w = imageBitmap.width;
|
||||
let h = imageBitmap.height;
|
||||
const MAX_W = 1000;
|
||||
const MAX_H = 1000;
|
||||
let w = imageBitmap.width;
|
||||
let h = imageBitmap.height;
|
||||
|
||||
if (w > MAX_W || h > MAX_H) {
|
||||
const scale = Math.min(MAX_W / w, MAX_H / h);
|
||||
w *= scale;
|
||||
h *= scale;
|
||||
if (w > MAX_W || h > MAX_H) {
|
||||
const scale = Math.min(MAX_W / w, MAX_H / h);
|
||||
w = Math.floor(w * scale);
|
||||
h = Math.floor(h * scale);
|
||||
}
|
||||
|
||||
renderer = new ImageEffector({
|
||||
canvas: canvasEl.value,
|
||||
renderWidth: w,
|
||||
renderHeight: h,
|
||||
image: imageBitmap,
|
||||
});
|
||||
|
||||
await renderer.render(layers);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts._imageEffector.failedToLoadImage,
|
||||
});
|
||||
}
|
||||
|
||||
renderer = new ImageEffector({
|
||||
canvas: canvasEl.value,
|
||||
renderWidth: w,
|
||||
renderHeight: h,
|
||||
image: imageBitmap,
|
||||
fxs: FXS,
|
||||
});
|
||||
|
||||
await renderer.setLayers(layers);
|
||||
|
||||
renderer.render();
|
||||
|
||||
closeWaiting();
|
||||
});
|
||||
|
||||
|
|
@ -196,7 +202,7 @@ async function save() {
|
|||
await nextTick(); // waitingがレンダリングされるまで待つ
|
||||
|
||||
renderer.changeResolution(imageBitmap.width, imageBitmap.height); // 本番レンダリングのためオリジナル画質に戻す
|
||||
renderer.render(); // toBlobの直前にレンダリングしないと何故か壊れる
|
||||
await renderer.render(layers); // toBlobの直前にレンダリングしないと何故か壊れる
|
||||
canvasEl.value.toBlob((blob) => {
|
||||
emit('ok', new File([blob!], `image-${Date.now()}.png`, { type: 'image/png' }));
|
||||
dialog.value?.close();
|
||||
|
|
@ -208,11 +214,10 @@ const enabled = ref(true);
|
|||
watch(enabled, () => {
|
||||
if (renderer != null) {
|
||||
if (enabled.value) {
|
||||
renderer.setLayers(layers);
|
||||
renderer.render(layers);
|
||||
} else {
|
||||
renderer.setLayers([]);
|
||||
renderer.render([]);
|
||||
}
|
||||
renderer.render();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -281,6 +286,7 @@ function onImagePointerdown(ev: PointerEvent) {
|
|||
angle: 0,
|
||||
opacity: 1,
|
||||
color: [1, 1, 1],
|
||||
ellipse: false,
|
||||
},
|
||||
});
|
||||
} else if (penMode.value === 'blur') {
|
||||
|
|
@ -294,6 +300,7 @@ function onImagePointerdown(ev: PointerEvent) {
|
|||
scaleY: 0.1,
|
||||
angle: 0,
|
||||
radius: 3,
|
||||
ellipse: false,
|
||||
},
|
||||
});
|
||||
} else if (penMode.value === 'pixelate') {
|
||||
|
|
@ -307,6 +314,7 @@ function onImagePointerdown(ev: PointerEvent) {
|
|||
scaleY: 0.1,
|
||||
angle: 0,
|
||||
strength: 0.2,
|
||||
ellipse: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -329,7 +337,7 @@ function onImagePointerdown(ev: PointerEvent) {
|
|||
const scaleY = Math.abs(y - startY);
|
||||
|
||||
const layerIndex = layers.findIndex((l) => l.id === id);
|
||||
const layer = layerIndex !== -1 ? layers[layerIndex] : null;
|
||||
const layer = layerIndex !== -1 ? (layers[layerIndex] as Extract<ImageEffectorLayer, { fxId: 'fill' } | { fxId: 'blur' } | { fxId: 'pixelate' }>) : null;
|
||||
if (layer != null) {
|
||||
layer.params.offsetX = (x + startX) - 1;
|
||||
layer.params.offsetY = (y + startY) - 1;
|
||||
|
|
@ -373,8 +381,17 @@ function onImagePointerdown(ev: PointerEvent) {
|
|||
.preview {
|
||||
position: relative;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-size: auto auto;
|
||||
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px);
|
||||
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.animatedBg {
|
||||
animation: bg 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bg {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: -20px -20px; }
|
||||
}
|
||||
|
||||
.previewContainer {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,509 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkModalWindow
|
||||
ref="dialog"
|
||||
:width="1000"
|
||||
:height="600"
|
||||
:scroll="false"
|
||||
:withOkButton="true"
|
||||
@close="cancel()"
|
||||
@ok="save()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header><i class="ti ti-device-ipad-horizontal"></i> {{ i18n.ts._imageFrameEditor.title }}</template>
|
||||
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.container">
|
||||
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
|
||||
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
|
||||
<div :class="$style.previewContainer">
|
||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
||||
<div v-if="props.image == null" class="_acrylic" :class="$style.previewControls">
|
||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button>
|
||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
|
||||
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.controls">
|
||||
<div class="_spacer _gaps">
|
||||
<MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkInput :modelValue="getHex(params.bgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.bgColor = c; }">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.backgroundColor }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput :modelValue="getHex(params.fgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.fgColor = c; }">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.textColor }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkSelect
|
||||
v-model="params.font" :items="[
|
||||
{ label: i18n.ts._imageFrameEditor.fontSansSerif, value: 'sans-serif' },
|
||||
{ label: i18n.ts._imageFrameEditor.fontSerif, value: 'serif' },
|
||||
]"
|
||||
>
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.font }}</template>
|
||||
</MkSelect>
|
||||
|
||||
<MkFolder :defaultOpen="params.labelTop.enabled">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.header }}</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="params.labelTop.enabled">
|
||||
<template #label>{{ i18n.ts.show }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkRange v-model="params.labelTop.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkRange v-model="params.labelTop.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkSwitch v-model="params.labelTop.centered">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkInput v-model="params.labelTop.textBig">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkTextarea v-model="params.labelTop.textSmall">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<MkSwitch v-model="params.labelTop.withQrCode">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder :defaultOpen="params.labelBottom.enabled">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.footer }}</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="params.labelBottom.enabled">
|
||||
<template #label>{{ i18n.ts.show }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkRange v-model="params.labelBottom.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkRange v-model="params.labelBottom.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkSwitch v-model="params.labelBottom.centered">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkInput v-model="params.labelBottom.textBig">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkTextarea v-model="params.labelBottom.textSmall">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<MkSwitch v-model="params.labelBottom.withQrCode">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkInfo>
|
||||
<div>{{ i18n.ts._imageFrameEditor.availableVariables }}:</div>
|
||||
<div><code class="_selectableAtomic">{filename}</code> - {{ i18n.ts._imageEditing._vars.filename }}</div>
|
||||
<div><code class="_selectableAtomic">{filename_without_ext}</code> - {{ i18n.ts._imageEditing._vars.filename_without_ext }}</div>
|
||||
<div><code class="_selectableAtomic">{caption}</code> - {{ i18n.ts._imageEditing._vars.caption }}</div>
|
||||
<div><code class="_selectableAtomic">{year}</code> - {{ i18n.ts._imageEditing._vars.year }}</div>
|
||||
<div><code class="_selectableAtomic">{month}</code> - {{ i18n.ts._imageEditing._vars.month }}</div>
|
||||
<div><code class="_selectableAtomic">{day}</code> - {{ i18n.ts._imageEditing._vars.day }}</div>
|
||||
<div><code class="_selectableAtomic">{hour}</code> - {{ i18n.ts._imageEditing._vars.hour }}</div>
|
||||
<div><code class="_selectableAtomic">{minute}</code> - {{ i18n.ts._imageEditing._vars.minute }}</div>
|
||||
<div><code class="_selectableAtomic">{second}</code> - {{ i18n.ts._imageEditing._vars.second }}</div>
|
||||
<div><code class="_selectableAtomic">{0month}</code> - {{ i18n.ts._imageEditing._vars.month }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0day}</code> - {{ i18n.ts._imageEditing._vars.day }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0hour}</code> - {{ i18n.ts._imageEditing._vars.hour }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0minute}</code> - {{ i18n.ts._imageEditing._vars.minute }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0second}</code> - {{ i18n.ts._imageEditing._vars.second }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{camera_model}</code> - {{ i18n.ts._imageEditing._vars.camera_model }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_lens_model}</code> - {{ i18n.ts._imageEditing._vars.camera_lens_model }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_mm}</code> - {{ i18n.ts._imageEditing._vars.camera_mm }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_mm_35}</code> - {{ i18n.ts._imageEditing._vars.camera_mm_35 }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_f}</code> - {{ i18n.ts._imageEditing._vars.camera_f }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_s}</code> - {{ i18n.ts._imageEditing._vars.camera_s }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_iso}</code> - {{ i18n.ts._imageEditing._vars.camera_iso }}</div>
|
||||
<div><code class="_selectableAtomic">{gps_lat}</code> - {{ i18n.ts._imageEditing._vars.gps_lat }}</div>
|
||||
<div><code class="_selectableAtomic">{gps_long}</code> - {{ i18n.ts._imageEditing._vars.gps_long }}</div>
|
||||
</MkInfo>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue';
|
||||
import ExifReader from 'exifreader';
|
||||
import { throttle } from 'throttle-debounce';
|
||||
import type { ImageFrameParams, ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
|
||||
import { ImageFrameRenderer } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkRange from '@/components/MkRange.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { useMkSelect } from '@/composables/use-mkselect.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
const props = defineProps<{
|
||||
presetEditMode?: boolean;
|
||||
preset?: ImageFramePreset | null;
|
||||
params?: ImageFrameParams | null;
|
||||
image?: File | null;
|
||||
imageCaption?: string | null;
|
||||
imageFilename?: string | null;
|
||||
}>();
|
||||
|
||||
const preset = deepClone(props.preset) ?? {
|
||||
id: genId(),
|
||||
name: '',
|
||||
};
|
||||
|
||||
const params = reactive<ImageFrameParams>(deepClone(props.params) ?? {
|
||||
borderThickness: 0.05,
|
||||
borderRadius: 0,
|
||||
labelTop: {
|
||||
enabled: false,
|
||||
scale: 1.0,
|
||||
padding: 0.2,
|
||||
textBig: '',
|
||||
textSmall: '',
|
||||
centered: false,
|
||||
withQrCode: false,
|
||||
},
|
||||
labelBottom: {
|
||||
enabled: true,
|
||||
scale: 1.0,
|
||||
padding: 0.2,
|
||||
textBig: '{year}/{0month}/{0day}',
|
||||
textSmall: '{camera_mm}mm f/{camera_f} {camera_s}s ISO{camera_iso}',
|
||||
centered: false,
|
||||
withQrCode: true,
|
||||
},
|
||||
bgColor: [1, 1, 1],
|
||||
fgColor: [0, 0, 0],
|
||||
font: 'sans-serif',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'ok', frame: ImageFrameParams): void;
|
||||
(ev: 'presetOk', preset: ImageFramePreset): void;
|
||||
(ev: 'cancel'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
async function cancel() {
|
||||
if (props.presetEditMode) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts._imageFrameEditor.quitWithoutSaveConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
}
|
||||
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
const updateThrottled = throttle(50, () => {
|
||||
if (renderer != null) {
|
||||
renderer.render(params);
|
||||
}
|
||||
});
|
||||
|
||||
watch(params, async (newValue, oldValue) => {
|
||||
updateThrottled();
|
||||
}, { deep: true });
|
||||
|
||||
const canvasEl = useTemplateRef('canvasEl');
|
||||
|
||||
const sampleImage_3_2 = new Image();
|
||||
sampleImage_3_2.src = '/client-assets/sample/3-2.jpg';
|
||||
const sampleImage_3_2_loading = new Promise<void>(resolve => {
|
||||
sampleImage_3_2.onload = () => resolve();
|
||||
});
|
||||
|
||||
const sampleImage_2_3 = new Image();
|
||||
sampleImage_2_3.src = '/client-assets/sample/2-3.jpg';
|
||||
const sampleImage_2_3_loading = new Promise<void>(resolve => {
|
||||
sampleImage_2_3.onload = () => resolve();
|
||||
});
|
||||
|
||||
const sampleImageType = ref(props.image != null ? 'provided' : '3_2');
|
||||
watch(sampleImageType, async () => {
|
||||
if (sampleImageType.value === 'provided') return;
|
||||
if (renderer != null) {
|
||||
renderer.destroy(false);
|
||||
renderer = null;
|
||||
initRenderer();
|
||||
}
|
||||
});
|
||||
|
||||
let imageFile = props.image;
|
||||
|
||||
async function choiceImage() {
|
||||
const files = await os.chooseFileFromPc({ multiple: false });
|
||||
if (files.length === 0) return;
|
||||
imageFile = files[0];
|
||||
sampleImageType.value = 'provided';
|
||||
if (renderer != null) {
|
||||
renderer.destroy(false);
|
||||
renderer = null;
|
||||
initRenderer();
|
||||
}
|
||||
}
|
||||
|
||||
let renderer: ImageFrameRenderer | null = null;
|
||||
let imageBitmap: ImageBitmap | null = null;
|
||||
|
||||
async function initRenderer() {
|
||||
if (canvasEl.value == null) return;
|
||||
|
||||
if (sampleImageType.value === '3_2') {
|
||||
renderer = new ImageFrameRenderer({
|
||||
canvas: canvasEl.value,
|
||||
image: sampleImage_3_2,
|
||||
exif: null,
|
||||
caption: 'Example caption',
|
||||
filename: 'example_file_name.jpg',
|
||||
renderAsPreview: true,
|
||||
});
|
||||
} else if (sampleImageType.value === '2_3') {
|
||||
renderer = new ImageFrameRenderer({
|
||||
canvas: canvasEl.value,
|
||||
image: sampleImage_2_3,
|
||||
exif: null,
|
||||
caption: 'Example caption',
|
||||
filename: 'example_file_name.jpg',
|
||||
renderAsPreview: true,
|
||||
});
|
||||
} else if (imageFile != null) {
|
||||
imageBitmap = await window.createImageBitmap(imageFile);
|
||||
|
||||
const exif = ExifReader.load(await imageFile.arrayBuffer());
|
||||
|
||||
renderer = new ImageFrameRenderer({
|
||||
canvas: canvasEl.value,
|
||||
image: imageBitmap,
|
||||
exif: exif,
|
||||
caption: props.imageCaption ?? null,
|
||||
filename: props.imageFilename ?? null,
|
||||
renderAsPreview: true,
|
||||
});
|
||||
}
|
||||
|
||||
await renderer!.render(params);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const closeWaiting = os.waiting();
|
||||
|
||||
await nextTick(); // waitingがレンダリングされるまで待つ
|
||||
|
||||
await sampleImage_3_2_loading;
|
||||
await sampleImage_2_3_loading;
|
||||
|
||||
try {
|
||||
await initRenderer();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts._imageFrameEditor.failedToLoadImage,
|
||||
});
|
||||
}
|
||||
|
||||
closeWaiting();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
if (imageBitmap != null) {
|
||||
imageBitmap.close();
|
||||
imageBitmap = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (props.presetEditMode) {
|
||||
const { canceled, result: name } = await os.inputText({
|
||||
title: i18n.ts.name,
|
||||
default: preset.name,
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
preset.name = name || '';
|
||||
|
||||
dialog.value?.close();
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
|
||||
emit('presetOk', {
|
||||
...preset,
|
||||
params: deepClone(params),
|
||||
});
|
||||
} else {
|
||||
dialog.value?.close();
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
|
||||
emit('ok', params);
|
||||
}
|
||||
}
|
||||
|
||||
function getHex(c: [number, number, number]) {
|
||||
return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
function getRgb(hex: string | number): [number, number, number] | null {
|
||||
if (
|
||||
typeof hex === 'number' ||
|
||||
typeof hex !== 'string' ||
|
||||
!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const m = hex.slice(1).match(/[0-9a-fA-F]{2}/g);
|
||||
if (m == null) return [0, 0, 0];
|
||||
return m.map(x => parseInt(x, 16) / 255) as [number, number, number];
|
||||
}
|
||||
</script>
|
||||
|
||||
<style module>
|
||||
.root {
|
||||
container-type: inline-size;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
position: relative;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.animatedBg {
|
||||
animation: bg 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bg {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: -20px -20px; }
|
||||
}
|
||||
|
||||
.previewContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.previewTitle {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.previewControls {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.previewControlsButton {
|
||||
&.active {
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.previewSpinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.previewCanvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.controls {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
@container (max-width: 800px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -345,7 +345,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { WatermarkPreset } from '@/utility/watermark.js';
|
||||
import type { WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
|
|
|
|||
|
|
@ -18,20 +18,21 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.preview">
|
||||
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
|
||||
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
|
||||
<div :class="$style.previewContainer">
|
||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
||||
<div v-if="props.image == null" class="_acrylic" :class="$style.previewControls">
|
||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button>
|
||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
|
||||
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.controls">
|
||||
<div class="_spacer _gaps">
|
||||
<div class="_gaps_s">
|
||||
<MkFolder v-for="(layer, i) in preset.layers" :key="layer.id" :defaultOpen="false" :canPage="false">
|
||||
<MkFolder v-for="(layer, i) in layers" :key="layer.id" :defaultOpen="false" :canPage="false">
|
||||
<template #label>
|
||||
<div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div>
|
||||
<div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div>
|
||||
|
|
@ -49,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<XLayer
|
||||
v-model:layer="preset.layers[i]"
|
||||
v-model:layer="layers[i]"
|
||||
></XLayer>
|
||||
</MkFolder>
|
||||
|
||||
|
|
@ -64,8 +65,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue';
|
||||
import type { WatermarkPreset } from '@/utility/watermark.js';
|
||||
import { WatermarkRenderer } from '@/utility/watermark.js';
|
||||
import type { WatermarkLayers, WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
|
||||
import { WatermarkRenderer } from '@/utility/watermark/WatermarkRenderer.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
|
|
@ -77,6 +78,7 @@ import { deepClone } from '@/utility/clone.js';
|
|||
import { ensureSignin } from '@/i.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { useMkSelect } from '@/composables/use-mkselect.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
|
|
@ -161,18 +163,22 @@ function createCheckerLayer(): WatermarkPreset['layers'][number] {
|
|||
}
|
||||
|
||||
const props = defineProps<{
|
||||
presetEditMode?: boolean;
|
||||
preset?: WatermarkPreset | null;
|
||||
layers?: WatermarkLayers | null;
|
||||
image?: File | null;
|
||||
}>();
|
||||
|
||||
const preset = reactive<WatermarkPreset>(deepClone(props.preset) ?? {
|
||||
const preset = deepClone(props.preset) ?? {
|
||||
id: genId(),
|
||||
name: '',
|
||||
layers: [],
|
||||
});
|
||||
};
|
||||
|
||||
const layers = reactive<WatermarkLayers>(props.layers ?? []);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'ok', preset: WatermarkPreset): void;
|
||||
(ev: 'ok', layers: WatermarkLayers): void;
|
||||
(ev: 'presetOk', preset: WatermarkPreset): void;
|
||||
(ev: 'cancel'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
|
@ -180,19 +186,21 @@ const emit = defineEmits<{
|
|||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
async function cancel() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
if (props.presetEditMode) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
}
|
||||
|
||||
emit('cancel');
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
watch(preset, async (newValue, oldValue) => {
|
||||
watch(layers, async (newValue, oldValue) => {
|
||||
if (renderer != null) {
|
||||
renderer.setLayers(preset.layers);
|
||||
renderer.render(layers);
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
|
|
@ -212,6 +220,7 @@ const sampleImage_2_3_loading = new Promise<void>(resolve => {
|
|||
|
||||
const sampleImageType = ref(props.image != null ? 'provided' : '3_2');
|
||||
watch(sampleImageType, async () => {
|
||||
if (sampleImageType.value === 'provided') return;
|
||||
if (renderer != null) {
|
||||
renderer.destroy(false);
|
||||
renderer = null;
|
||||
|
|
@ -219,6 +228,20 @@ watch(sampleImageType, async () => {
|
|||
}
|
||||
});
|
||||
|
||||
let imageFile = props.image;
|
||||
|
||||
async function choiceImage() {
|
||||
const files = await os.chooseFileFromPc({ multiple: false });
|
||||
if (files.length === 0) return;
|
||||
imageFile = files[0];
|
||||
sampleImageType.value = 'provided';
|
||||
if (renderer != null) {
|
||||
renderer.destroy(false);
|
||||
renderer = null;
|
||||
initRenderer();
|
||||
}
|
||||
}
|
||||
|
||||
let renderer: WatermarkRenderer | null = null;
|
||||
let imageBitmap: ImageBitmap | null = null;
|
||||
|
||||
|
|
@ -239,8 +262,8 @@ async function initRenderer() {
|
|||
renderHeight: 1500,
|
||||
image: sampleImage_2_3,
|
||||
});
|
||||
} else if (props.image != null) {
|
||||
imageBitmap = await window.createImageBitmap(props.image);
|
||||
} else if (imageFile != null) {
|
||||
imageBitmap = await window.createImageBitmap(imageFile);
|
||||
|
||||
const MAX_W = 1000;
|
||||
const MAX_H = 1000;
|
||||
|
|
@ -249,8 +272,8 @@ async function initRenderer() {
|
|||
|
||||
if (w > MAX_W || h > MAX_H) {
|
||||
const scale = Math.min(MAX_W / w, MAX_H / h);
|
||||
w *= scale;
|
||||
h *= scale;
|
||||
w = Math.floor(w * scale);
|
||||
h = Math.floor(h * scale);
|
||||
}
|
||||
|
||||
renderer = new WatermarkRenderer({
|
||||
|
|
@ -261,9 +284,7 @@ async function initRenderer() {
|
|||
});
|
||||
}
|
||||
|
||||
await renderer!.setLayers(preset.layers);
|
||||
|
||||
renderer!.render();
|
||||
await renderer!.render(layers);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
|
@ -274,7 +295,15 @@ onMounted(async () => {
|
|||
await sampleImage_3_2_loading;
|
||||
await sampleImage_2_3_loading;
|
||||
|
||||
await initRenderer();
|
||||
try {
|
||||
await initRenderer();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts._watermarkEditor.failedToLoadImage,
|
||||
});
|
||||
}
|
||||
|
||||
closeWaiting();
|
||||
});
|
||||
|
|
@ -291,77 +320,93 @@ onUnmounted(() => {
|
|||
});
|
||||
|
||||
async function save() {
|
||||
const { canceled, result: name } = await os.inputText({
|
||||
title: i18n.ts.name,
|
||||
default: preset.name,
|
||||
});
|
||||
if (canceled) return;
|
||||
if (props.presetEditMode) {
|
||||
const { canceled, result: name } = await os.inputText({
|
||||
title: i18n.ts.name,
|
||||
default: preset.name,
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
preset.name = name || '';
|
||||
preset.name = name || '';
|
||||
|
||||
dialog.value?.close();
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
dialog.value?.close();
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
|
||||
emit('presetOk', {
|
||||
...preset,
|
||||
layers: deepClone(layers),
|
||||
});
|
||||
} else {
|
||||
dialog.value?.close();
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
|
||||
emit('ok', layers);
|
||||
}
|
||||
|
||||
emit('ok', preset);
|
||||
}
|
||||
|
||||
function addLayer(ev: MouseEvent) {
|
||||
os.popupMenu([{
|
||||
text: i18n.ts._watermarkEditor.text,
|
||||
action: () => {
|
||||
preset.layers.push(createTextLayer());
|
||||
layers.push(createTextLayer());
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._watermarkEditor.image,
|
||||
action: () => {
|
||||
preset.layers.push(createImageLayer());
|
||||
layers.push(createImageLayer());
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._watermarkEditor.qr,
|
||||
action: () => {
|
||||
preset.layers.push(createQrLayer());
|
||||
layers.push(createQrLayer());
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._watermarkEditor.stripe,
|
||||
action: () => {
|
||||
preset.layers.push(createStripeLayer());
|
||||
layers.push(createStripeLayer());
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._watermarkEditor.polkadot,
|
||||
action: () => {
|
||||
preset.layers.push(createPolkadotLayer());
|
||||
layers.push(createPolkadotLayer());
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._watermarkEditor.checker,
|
||||
action: () => {
|
||||
preset.layers.push(createCheckerLayer());
|
||||
layers.push(createCheckerLayer());
|
||||
},
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
function swapUpLayer(layer: WatermarkPreset['layers'][number]) {
|
||||
const index = preset.layers.findIndex(l => l.id === layer.id);
|
||||
const index = layers.findIndex(l => l.id === layer.id);
|
||||
if (index > 0) {
|
||||
const tmp = preset.layers[index - 1];
|
||||
preset.layers[index - 1] = preset.layers[index];
|
||||
preset.layers[index] = tmp;
|
||||
const tmp = layers[index - 1];
|
||||
layers[index - 1] = layers[index];
|
||||
layers[index] = tmp;
|
||||
}
|
||||
}
|
||||
|
||||
function swapDownLayer(layer: WatermarkPreset['layers'][number]) {
|
||||
const index = preset.layers.findIndex(l => l.id === layer.id);
|
||||
if (index < preset.layers.length - 1) {
|
||||
const tmp = preset.layers[index + 1];
|
||||
preset.layers[index + 1] = preset.layers[index];
|
||||
preset.layers[index] = tmp;
|
||||
const index = layers.findIndex(l => l.id === layer.id);
|
||||
if (index < layers.length - 1) {
|
||||
const tmp = layers[index + 1];
|
||||
layers[index + 1] = layers[index];
|
||||
layers[index] = tmp;
|
||||
}
|
||||
}
|
||||
|
||||
function removeLayer(layer: WatermarkPreset['layers'][number]) {
|
||||
preset.layers = preset.layers.filter(l => l.id !== layer.id);
|
||||
const index = layers.findIndex(l => l.id === layer.id);
|
||||
if (index !== -1) {
|
||||
layers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -380,8 +425,17 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) {
|
|||
.preview {
|
||||
position: relative;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-size: auto auto;
|
||||
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px);
|
||||
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.animatedBg {
|
||||
animation: bg 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bg {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: -20px -20px; }
|
||||
}
|
||||
|
||||
.previewContainer {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import isAnimated from 'is-file-animated';
|
|||
import { EventEmitter } from 'eventemitter3';
|
||||
import { computed, markRaw, onMounted, onUnmounted, ref, triggerRef } from 'vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import type { WatermarkLayers, WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
|
||||
import type { ImageFrameParams, ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
|
@ -16,7 +18,6 @@ import { isWebpSupported } from '@/utility/isWebpSupported.js';
|
|||
import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
|
||||
import * as os from '@/os.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import { WatermarkRenderer } from '@/utility/watermark.js';
|
||||
|
||||
export type UploaderFeatures = {
|
||||
imageEditing?: boolean;
|
||||
|
|
@ -28,13 +29,7 @@ const THUMBNAIL_SUPPORTED_TYPES = [
|
|||
'image/png',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
];
|
||||
|
||||
const IMAGE_COMPRESSION_SUPPORTED_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
'image/gif',
|
||||
];
|
||||
|
||||
const IMAGE_EDITING_SUPPORTED_TYPES = [
|
||||
|
|
@ -49,11 +44,7 @@ const VIDEO_COMPRESSION_SUPPORTED_TYPES = [ // TODO
|
|||
'video/x-matroska',
|
||||
];
|
||||
|
||||
const WATERMARK_SUPPORTED_TYPES = IMAGE_EDITING_SUPPORTED_TYPES;
|
||||
|
||||
const IMAGE_PREPROCESS_NEEDED_TYPES = [
|
||||
...WATERMARK_SUPPORTED_TYPES,
|
||||
...IMAGE_COMPRESSION_SUPPORTED_TYPES,
|
||||
...IMAGE_EDITING_SUPPORTED_TYPES,
|
||||
];
|
||||
|
||||
|
|
@ -83,7 +74,9 @@ export type UploaderItem = {
|
|||
compressedSize?: number | null;
|
||||
preprocessedFile?: Blob | null;
|
||||
file: File;
|
||||
watermarkPresetId: string | null;
|
||||
watermarkPreset: WatermarkPreset | null;
|
||||
watermarkLayers: WatermarkLayers | null;
|
||||
imageFrameParams: ImageFrameParams | null;
|
||||
isSensitive?: boolean;
|
||||
caption?: string | null;
|
||||
abort?: (() => void) | null;
|
||||
|
|
@ -135,6 +128,7 @@ export function useUploader(options: {
|
|||
const id = genId();
|
||||
const filename = file.name ?? 'untitled';
|
||||
const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
|
||||
const watermarkPreset = uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? (prefer.s.watermarkPresets.find(p => p.id === prefer.s.defaultWatermarkPresetId) ?? null) : null;
|
||||
items.value.push({
|
||||
id,
|
||||
name: prefer.s.keepOriginalFilename ? filename : id + extension,
|
||||
|
|
@ -146,8 +140,10 @@ export function useUploader(options: {
|
|||
aborted: false,
|
||||
uploaded: null,
|
||||
uploadFailed: false,
|
||||
compressionLevel: IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultImageCompressionLevel : VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultVideoCompressionLevel : 0,
|
||||
watermarkPresetId: uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? prefer.s.defaultWatermarkPresetId : null,
|
||||
compressionLevel: IMAGE_EDITING_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultImageCompressionLevel : VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultVideoCompressionLevel : 0,
|
||||
watermarkPreset,
|
||||
watermarkLayers: watermarkPreset?.layers ?? null,
|
||||
imageFrameParams: null,
|
||||
file: markRaw(file),
|
||||
});
|
||||
const reactiveItem = items.value.at(-1)!;
|
||||
|
|
@ -253,7 +249,7 @@ export function useUploader(options: {
|
|||
},
|
||||
},*/ {
|
||||
icon: 'ti ti-sparkles',
|
||||
text: i18n.ts._imageEffector.title + ' (BETA)',
|
||||
text: i18n.ts._imageEffector.title,
|
||||
action: async () => {
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageEffectorDialog.vue').then(x => x.default), {
|
||||
image: item.file,
|
||||
|
|
@ -280,13 +276,14 @@ export function useUploader(options: {
|
|||
if (
|
||||
uploaderFeatures.value.watermark &&
|
||||
$i.policies.watermarkAvailable &&
|
||||
WATERMARK_SUPPORTED_TYPES.includes(item.file.type) &&
|
||||
IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) &&
|
||||
!item.preprocessing &&
|
||||
!item.uploading &&
|
||||
!item.uploaded
|
||||
) {
|
||||
function changeWatermarkPreset(presetId: string | null) {
|
||||
item.watermarkPresetId = presetId;
|
||||
function change(layers: WatermarkLayers | null, preset?: WatermarkPreset | null) {
|
||||
item.watermarkPreset = preset ?? null;
|
||||
item.watermarkLayers = layers;
|
||||
preprocess(item).then(() => {
|
||||
triggerRef(items);
|
||||
});
|
||||
|
|
@ -295,43 +292,109 @@ export function useUploader(options: {
|
|||
menu.push({
|
||||
icon: 'ti ti-copyright',
|
||||
text: i18n.ts.watermark,
|
||||
caption: computed(() => item.watermarkPresetId == null ? null : prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId)?.name),
|
||||
caption: computed(() => item.watermarkPreset != null ? item.watermarkPreset.name : item.watermarkLayers != null ? i18n.ts.custom : null),
|
||||
type: 'parent',
|
||||
children: [{
|
||||
type: 'radioOption',
|
||||
text: i18n.ts.none,
|
||||
active: computed(() => item.watermarkPresetId == null),
|
||||
action: () => changeWatermarkPreset(null),
|
||||
}, {
|
||||
type: 'divider',
|
||||
}, ...prefer.s.watermarkPresets.map(preset => ({
|
||||
type: 'radioOption' as const,
|
||||
text: preset.name,
|
||||
active: computed(() => item.watermarkPresetId === preset.id),
|
||||
action: () => changeWatermarkPreset(preset.id),
|
||||
})), ...(prefer.s.watermarkPresets.length > 0 ? [{
|
||||
type: 'divider' as const,
|
||||
}] : []), {
|
||||
type: 'button',
|
||||
icon: 'ti ti-plus',
|
||||
text: i18n.ts.add,
|
||||
type: 'button' as const,
|
||||
icon: 'ti ti-pencil',
|
||||
text: i18n.ts.edit,
|
||||
action: async () => {
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkWatermarkEditorDialog.vue').then(x => x.default), {
|
||||
layers: item.watermarkLayers,
|
||||
image: item.file,
|
||||
}, {
|
||||
ok: (preset) => {
|
||||
prefer.commit('watermarkPresets', [...prefer.s.watermarkPresets, preset]);
|
||||
changeWatermarkPreset(preset.id);
|
||||
ok: (layers) => {
|
||||
change(layers);
|
||||
},
|
||||
closed: () => dispose(),
|
||||
});
|
||||
},
|
||||
}],
|
||||
}, {
|
||||
type: 'button' as const,
|
||||
icon: 'ti ti-x',
|
||||
text: i18n.ts.remove,
|
||||
action: () => change(null),
|
||||
}, {
|
||||
type: 'divider',
|
||||
}, {
|
||||
type: 'label',
|
||||
text: i18n.ts.presets,
|
||||
}, ...prefer.s.watermarkPresets.map(preset => ({
|
||||
type: 'radioOption' as const,
|
||||
text: preset.name,
|
||||
active: computed(() => item.watermarkPreset?.id === preset.id),
|
||||
action: () => change(preset.layers, preset),
|
||||
}))],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) || VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type)) &&
|
||||
uploaderFeatures.value.imageEditing &&
|
||||
IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) &&
|
||||
!item.preprocessing &&
|
||||
!item.uploading &&
|
||||
!item.uploaded
|
||||
) {
|
||||
function change(params: ImageFrameParams | null) {
|
||||
item.imageFrameParams = params;
|
||||
preprocess(item).then(() => {
|
||||
triggerRef(items);
|
||||
});
|
||||
}
|
||||
|
||||
menu.push({
|
||||
icon: 'ti ti-device-ipad-horizontal',
|
||||
text: i18n.ts.frame,
|
||||
type: 'parent' as const,
|
||||
children: [{
|
||||
type: 'button' as const,
|
||||
icon: 'ti ti-pencil',
|
||||
text: i18n.ts.edit,
|
||||
action: async () => {
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageFrameEditorDialog.vue').then(x => x.default), {
|
||||
params: item.imageFrameParams,
|
||||
image: item.file,
|
||||
imageCaption: item.caption ?? null,
|
||||
imageFilename: item.name,
|
||||
}, {
|
||||
ok: (params) => {
|
||||
change(params);
|
||||
},
|
||||
closed: () => dispose(),
|
||||
});
|
||||
},
|
||||
}, ...(item.imageFrameParams != null ? [{
|
||||
type: 'button' as const,
|
||||
icon: 'ti ti-x',
|
||||
text: i18n.ts.remove,
|
||||
action: () => change(null),
|
||||
}] : []), {
|
||||
type: 'divider' as const,
|
||||
}, {
|
||||
type: 'label' as const,
|
||||
text: i18n.ts.presets,
|
||||
}, ...prefer.s.imageFramePresets.map(preset => ({
|
||||
type: 'button' as const,
|
||||
text: preset.name,
|
||||
action: async () => {
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageFrameEditorDialog.vue').then(x => x.default), {
|
||||
params: preset.params,
|
||||
image: item.file,
|
||||
imageCaption: item.caption ?? null,
|
||||
imageFilename: item.name,
|
||||
}, {
|
||||
ok: (params) => {
|
||||
change(params);
|
||||
},
|
||||
closed: () => dispose(),
|
||||
});
|
||||
},
|
||||
}))],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) || VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type)) &&
|
||||
!item.preprocessing &&
|
||||
!item.uploading &&
|
||||
!item.uploaded
|
||||
|
|
@ -545,10 +608,10 @@ export function useUploader(options: {
|
|||
|
||||
let preprocessedFile: Blob | File = item.file;
|
||||
|
||||
const needsWatermark = item.watermarkPresetId != null && WATERMARK_SUPPORTED_TYPES.includes(preprocessedFile.type) && $i.policies.watermarkAvailable;
|
||||
const preset = prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId);
|
||||
if (needsWatermark && preset != null) {
|
||||
const needsWatermark = item.watermarkLayers != null && IMAGE_EDITING_SUPPORTED_TYPES.includes(preprocessedFile.type) && $i.policies.watermarkAvailable;
|
||||
if (needsWatermark && item.watermarkLayers != null) {
|
||||
const canvas = window.document.createElement('canvas');
|
||||
const WatermarkRenderer = await import('@/utility/watermark/WatermarkRenderer.js').then(x => x.WatermarkRenderer);
|
||||
const renderer = new WatermarkRenderer({
|
||||
canvas: canvas,
|
||||
renderWidth: imageBitmap.width,
|
||||
|
|
@ -556,9 +619,7 @@ export function useUploader(options: {
|
|||
image: imageBitmap,
|
||||
});
|
||||
|
||||
await renderer.setLayers(preset.layers);
|
||||
|
||||
renderer.render();
|
||||
await renderer.render(item.watermarkLayers);
|
||||
|
||||
preprocessedFile = await new Promise<Blob>((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
|
|
@ -571,8 +632,35 @@ export function useUploader(options: {
|
|||
});
|
||||
}
|
||||
|
||||
const needsImageFrame = item.imageFrameParams != null && IMAGE_EDITING_SUPPORTED_TYPES.includes(preprocessedFile.type);
|
||||
if (needsImageFrame && item.imageFrameParams != null) {
|
||||
const canvas = window.document.createElement('canvas');
|
||||
const ExifReader = await import('exifreader');
|
||||
const exif = await ExifReader.load(await item.file.arrayBuffer());
|
||||
const ImageFrameRenderer = await import('@/utility/image-frame-renderer/ImageFrameRenderer.js').then(x => x.ImageFrameRenderer);
|
||||
const frameRenderer = new ImageFrameRenderer({
|
||||
canvas: canvas,
|
||||
image: await window.createImageBitmap(preprocessedFile),
|
||||
exif,
|
||||
caption: item.caption ?? null,
|
||||
filename: item.name,
|
||||
});
|
||||
|
||||
await frameRenderer.render(item.imageFrameParams);
|
||||
|
||||
preprocessedFile = await new Promise<Blob>((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob == null) {
|
||||
throw new Error('Failed to convert canvas to blob');
|
||||
}
|
||||
resolve(blob);
|
||||
frameRenderer.destroy();
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
const compressionSettings = getCompressionSettings(item.compressionLevel);
|
||||
const needsCompress = item.compressionLevel !== 0 && compressionSettings && IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(preprocessedFile.type) && !(await isAnimated(preprocessedFile));
|
||||
const needsCompress = item.compressionLevel !== 0 && compressionSettings && IMAGE_EDITING_SUPPORTED_TYPES.includes(preprocessedFile.type) && !(await isAnimated(preprocessedFile));
|
||||
|
||||
if (needsCompress) {
|
||||
const config = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,313 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { createTexture, initShaderProgram } from '../utility/webgl.js';
|
||||
|
||||
export type ImageCompositorFunctionParams = Record<string, any>;
|
||||
|
||||
export type ImageCompositorFunction<PS extends ImageCompositorFunctionParams = ImageCompositorFunctionParams> = {
|
||||
shader: string;
|
||||
main: (ctx: {
|
||||
gl: WebGL2RenderingContext;
|
||||
program: WebGLProgram;
|
||||
params: PS;
|
||||
u: Record<string, WebGLUniformLocation>;
|
||||
width: number;
|
||||
height: number;
|
||||
textures: Map<string, { texture: WebGLTexture; width: number; height: number; }>;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export type ImageCompositorLayer<FNS extends Record<string, ImageCompositorFunction> = any> = {
|
||||
[K in keyof FNS]: {
|
||||
id: string;
|
||||
functionId: K;
|
||||
params: Parameters<FNS[K]['main']>[0]['params'];
|
||||
};
|
||||
}[keyof FNS];
|
||||
|
||||
export function defineImageCompositorFunction<PS extends ImageCompositorFunctionParams>(fn: ImageCompositorFunction<PS>) {
|
||||
return fn;
|
||||
}
|
||||
|
||||
// TODO: per layer cache
|
||||
|
||||
export class ImageCompositor<FNS extends Record<string, ImageCompositorFunction<any>>> {
|
||||
private gl: WebGL2RenderingContext;
|
||||
private canvas: HTMLCanvasElement | null = null;
|
||||
private renderWidth: number;
|
||||
private renderHeight: number;
|
||||
private baseTexture: WebGLTexture;
|
||||
private shaderCache: Map<string, WebGLProgram> = new Map();
|
||||
private perLayerResultTextures: Map<string, WebGLTexture> = new Map();
|
||||
private perLayerResultFrameBuffers: Map<string, WebGLFramebuffer> = new Map();
|
||||
private nopProgram: WebGLProgram;
|
||||
private registeredTextures: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map();
|
||||
private registeredFunctions: Map<string, ImageCompositorFunction & { id: string; uniforms: string[] }> = new Map();
|
||||
|
||||
constructor(options: {
|
||||
canvas: HTMLCanvasElement;
|
||||
renderWidth: number;
|
||||
renderHeight: number;
|
||||
image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement | null;
|
||||
functions: FNS;
|
||||
}) {
|
||||
this.canvas = options.canvas;
|
||||
this.renderWidth = options.renderWidth;
|
||||
this.renderHeight = options.renderHeight;
|
||||
|
||||
this.canvas.width = this.renderWidth;
|
||||
this.canvas.height = this.renderHeight;
|
||||
|
||||
const gl = this.canvas.getContext('webgl2', {
|
||||
preserveDrawingBuffer: false,
|
||||
alpha: true,
|
||||
premultipliedAlpha: false,
|
||||
});
|
||||
|
||||
if (gl == null) throw new Error('Failed to initialize WebGL2 context');
|
||||
|
||||
this.gl = gl;
|
||||
|
||||
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
|
||||
|
||||
const VERTICES = new Float32Array([-1, -1, -1, 1, 1, 1, -1, -1, 1, 1, 1, -1]);
|
||||
const vertexBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
|
||||
|
||||
if (options.image != null) {
|
||||
this.baseTexture = createTexture(gl);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.baseTexture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, options.image.width, options.image.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, options.image);
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
} else {
|
||||
this.baseTexture = createTexture(gl);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
}
|
||||
|
||||
this.nopProgram = initShaderProgram(this.gl, `#version 300 es
|
||||
in vec2 position;
|
||||
out vec2 in_uv;
|
||||
|
||||
void main() {
|
||||
in_uv = (position + 1.0) / 2.0;
|
||||
gl_Position = vec4(position * vec2(1.0, -1.0), 0.0, 1.0);
|
||||
}
|
||||
`, `#version 300 es
|
||||
precision mediump float;
|
||||
|
||||
in vec2 in_uv;
|
||||
uniform sampler2D u_texture;
|
||||
out vec4 out_color;
|
||||
|
||||
void main() {
|
||||
out_color = texture(u_texture, in_uv);
|
||||
}
|
||||
`);
|
||||
|
||||
// レジスタ番号はシェーダープログラムに属しているわけではなく、独立の存在なので、とりあえず nopProgram を使って設定する(その後は効果が持続する)
|
||||
// ref. https://qiita.com/emadurandal/items/5966c8374f03d4de3266
|
||||
const positionLocation = gl.getAttribLocation(this.nopProgram, 'position');
|
||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.enableVertexAttribArray(positionLocation);
|
||||
|
||||
for (const [id, fn] of Object.entries(options.functions)) {
|
||||
const uniforms = this.extractUniformNamesFromShader(fn.shader);
|
||||
this.registeredFunctions.set(id, { ...fn, id, uniforms });
|
||||
}
|
||||
}
|
||||
|
||||
private extractUniformNamesFromShader(shader: string): string[] {
|
||||
const uniformRegex = /uniform\s+\w+\s+(\w+)\s*;/g;
|
||||
const uniforms: string[] = [];
|
||||
let match;
|
||||
while ((match = uniformRegex.exec(shader)) !== null) {
|
||||
uniforms.push(match[1].replace(/^u_/, ''));
|
||||
}
|
||||
return uniforms;
|
||||
}
|
||||
|
||||
private renderLayer(layer: ImageCompositorLayer, preTexture: WebGLTexture, invert = false) {
|
||||
const gl = this.gl;
|
||||
|
||||
const fn = this.registeredFunctions.get(layer.functionId);
|
||||
if (fn == null) return;
|
||||
|
||||
const cachedShader = this.shaderCache.get(fn.id);
|
||||
const shaderProgram = cachedShader ?? initShaderProgram(this.gl, `#version 300 es
|
||||
in vec2 position;
|
||||
uniform bool u_invert;
|
||||
out vec2 in_uv;
|
||||
|
||||
void main() {
|
||||
in_uv = (position + 1.0) / 2.0;
|
||||
gl_Position = u_invert ? vec4(position * vec2(1.0, -1.0), 0.0, 1.0) : vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`, fn.shader);
|
||||
if (cachedShader == null) {
|
||||
this.shaderCache.set(fn.id, shaderProgram);
|
||||
}
|
||||
|
||||
gl.useProgram(shaderProgram);
|
||||
|
||||
const in_resolution = gl.getUniformLocation(shaderProgram, 'in_resolution');
|
||||
gl.uniform2fv(in_resolution, [this.renderWidth, this.renderHeight]);
|
||||
|
||||
const u_invert = gl.getUniformLocation(shaderProgram, 'u_invert');
|
||||
gl.uniform1i(u_invert, invert ? 1 : 0);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, preTexture);
|
||||
const in_texture = gl.getUniformLocation(shaderProgram, 'in_texture');
|
||||
gl.uniform1i(in_texture, 0);
|
||||
|
||||
fn.main({
|
||||
gl: gl,
|
||||
program: shaderProgram,
|
||||
params: layer.params,
|
||||
u: Object.fromEntries(fn.uniforms.map(u => [u, gl.getUniformLocation(shaderProgram, 'u_' + u)!])),
|
||||
width: this.renderWidth,
|
||||
height: this.renderHeight,
|
||||
textures: this.registeredTextures,
|
||||
});
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
}
|
||||
|
||||
public render(layers: (ImageCompositorLayer<FNS>)[]) {
|
||||
const gl = this.gl;
|
||||
|
||||
// 入力をそのまま出力
|
||||
if (layers.length === 0) {
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.baseTexture);
|
||||
|
||||
gl.useProgram(this.nopProgram);
|
||||
gl.uniform1i(gl.getUniformLocation(this.nopProgram, 'u_texture')!, 0);
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
return;
|
||||
}
|
||||
|
||||
let preTexture = this.baseTexture;
|
||||
|
||||
for (const layer of layers) {
|
||||
const isLast = layer === layers.at(-1);
|
||||
|
||||
const cachedResultTexture = this.perLayerResultTextures.get(layer.id);
|
||||
const resultTexture = cachedResultTexture ?? createTexture(gl);
|
||||
if (cachedResultTexture == null) {
|
||||
this.perLayerResultTextures.set(layer.id, resultTexture);
|
||||
}
|
||||
gl.bindTexture(gl.TEXTURE_2D, resultTexture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.renderWidth, this.renderHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
|
||||
if (isLast) {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
} else {
|
||||
const cachedResultFrameBuffer = this.perLayerResultFrameBuffers.get(layer.id);
|
||||
const resultFrameBuffer = cachedResultFrameBuffer ?? gl.createFramebuffer();
|
||||
if (cachedResultFrameBuffer == null) {
|
||||
this.perLayerResultFrameBuffers.set(layer.id, resultFrameBuffer);
|
||||
}
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, resultFrameBuffer);
|
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, resultTexture, 0);
|
||||
}
|
||||
|
||||
this.renderLayer(layer, preTexture, isLast);
|
||||
|
||||
preTexture = resultTexture;
|
||||
}
|
||||
}
|
||||
|
||||
public registerTexture(key: string, image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement) {
|
||||
const gl = this.gl;
|
||||
|
||||
const existing = this.registeredTextures.get(key);
|
||||
if (existing != null) {
|
||||
gl.deleteTexture(existing.texture);
|
||||
this.registeredTextures.delete(key);
|
||||
}
|
||||
|
||||
const texture = createTexture(gl);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, image.width, image.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
|
||||
this.registeredTextures.set(key, {
|
||||
texture: texture,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
});
|
||||
}
|
||||
|
||||
public unregisterTexture(key: string) {
|
||||
const gl = this.gl;
|
||||
|
||||
const existing = this.registeredTextures.get(key);
|
||||
if (existing != null) {
|
||||
gl.deleteTexture(existing.texture);
|
||||
this.registeredTextures.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
public hasTexture(key: string) {
|
||||
return this.registeredTextures.has(key);
|
||||
}
|
||||
|
||||
public getKeysOfRegisteredTextures() {
|
||||
return this.registeredTextures.keys();
|
||||
}
|
||||
|
||||
public changeResolution(width: number, height: number) {
|
||||
if (this.renderWidth === width && this.renderHeight === height) return;
|
||||
|
||||
this.renderWidth = width;
|
||||
this.renderHeight = height;
|
||||
if (this.canvas) {
|
||||
this.canvas.width = this.renderWidth;
|
||||
this.canvas.height = this.renderHeight;
|
||||
}
|
||||
this.gl.viewport(0, 0, this.renderWidth, this.renderHeight);
|
||||
}
|
||||
|
||||
/*
|
||||
* disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意
|
||||
*/
|
||||
public destroy(disposeCanvas = true) {
|
||||
this.gl.deleteProgram(this.nopProgram);
|
||||
|
||||
for (const shader of this.shaderCache.values()) {
|
||||
this.gl.deleteProgram(shader);
|
||||
}
|
||||
this.shaderCache.clear();
|
||||
|
||||
for (const texture of this.perLayerResultTextures.values()) {
|
||||
this.gl.deleteTexture(texture);
|
||||
}
|
||||
this.perLayerResultTextures.clear();
|
||||
|
||||
for (const framebuffer of this.perLayerResultFrameBuffers.values()) {
|
||||
this.gl.deleteFramebuffer(framebuffer);
|
||||
}
|
||||
this.perLayerResultFrameBuffers.clear();
|
||||
|
||||
for (const texture of this.registeredTextures.values()) {
|
||||
this.gl.deleteTexture(texture.texture);
|
||||
}
|
||||
this.registeredTextures.clear();
|
||||
|
||||
this.gl.deleteTexture(this.baseTexture);
|
||||
|
||||
if (disposeCanvas) {
|
||||
const loseContextExt = this.gl.getExtension('WEBGL_lose_context');
|
||||
if (loseContextExt) loseContextExt.loseContext();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@
|
|||
|
||||
// PIZZAX --- A lightweight store
|
||||
|
||||
// TODO: Misskeyのドメイン知識があるのでutilityなどに移動する
|
||||
|
||||
import { onUnmounted, ref, watch } from 'vue';
|
||||
import { BroadcastChannel } from 'broadcast-channel';
|
||||
import type { Ref } from 'vue';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkFolder :defaultOpen="false" :canPage="false">
|
||||
<template #icon><i class="ti ti-pencil"></i></template>
|
||||
<template #label>{{ i18n.ts.preset }}: {{ preset.name === '' ? '(' + i18n.ts.noName + ')' : preset.name }}</template>
|
||||
<template #footer>
|
||||
<div class="_buttons">
|
||||
<MkButton @click="edit"><i class="ti ti-pencil"></i> {{ i18n.ts.edit }}</MkButton>
|
||||
<MkButton danger iconOnly style="margin-left: auto;" @click="del"><i class="ti ti-trash"></i></MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import type { ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
|
||||
import { ImageFrameRenderer } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
preset: ImageFramePreset;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'updatePreset', preset: ImageFramePreset): void,
|
||||
(ev: 'del'): void,
|
||||
}>();
|
||||
|
||||
async function edit() {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkImageFrameEditorDialog.vue')), {
|
||||
presetEditMode: true,
|
||||
preset: deepClone(props.preset),
|
||||
params: deepClone(props.preset.params),
|
||||
}, {
|
||||
presetOk: (preset) => {
|
||||
emit('updatePreset', preset);
|
||||
},
|
||||
closed: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
function del(ev: MouseEvent) {
|
||||
os.popupMenu([{
|
||||
text: i18n.ts.delete,
|
||||
action: () => {
|
||||
emit('del');
|
||||
},
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
const canvasEl = useTemplateRef('canvasEl');
|
||||
|
||||
const sampleImage = new Image();
|
||||
sampleImage.src = '/client-assets/sample/3-2.jpg';
|
||||
|
||||
let renderer: ImageFrameRenderer | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
sampleImage.onload = async () => {
|
||||
watch(canvasEl, async () => {
|
||||
if (canvasEl.value == null) return;
|
||||
|
||||
renderer = new ImageFrameRenderer({
|
||||
canvas: canvasEl.value,
|
||||
image: sampleImage,
|
||||
exif: null,
|
||||
caption: 'Example caption',
|
||||
filename: 'example_file_name.jpg',
|
||||
renderAsPreview: true,
|
||||
});
|
||||
|
||||
await renderer.render(props.preset.params);
|
||||
}, { immediate: true });
|
||||
};
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.preset, async () => {
|
||||
if (renderer != null) {
|
||||
await renderer.render(props.preset.params);
|
||||
}
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.previewCanvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 200px;
|
||||
box-sizing: border-box;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -22,8 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import type { WatermarkPreset } from '@/utility/watermark.js';
|
||||
import { WatermarkRenderer } from '@/utility/watermark.js';
|
||||
import type { WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
|
||||
import { WatermarkRenderer } from '@/utility/watermark/WatermarkRenderer.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
|
@ -41,9 +41,11 @@ const emit = defineEmits<{
|
|||
|
||||
async function edit() {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkWatermarkEditorDialog.vue')), {
|
||||
presetEditMode: true,
|
||||
preset: deepClone(props.preset),
|
||||
layers: deepClone(props.preset.layers),
|
||||
}, {
|
||||
ok: (preset) => {
|
||||
presetOk: (preset) => {
|
||||
emit('updatePreset', preset);
|
||||
},
|
||||
closed: () => dispose(),
|
||||
|
|
@ -78,9 +80,7 @@ onMounted(() => {
|
|||
image: sampleImage,
|
||||
});
|
||||
|
||||
await renderer.setLayers(props.preset.layers);
|
||||
|
||||
renderer.render();
|
||||
await renderer.render(props.preset.layers);
|
||||
}, { immediate: true });
|
||||
};
|
||||
});
|
||||
|
|
@ -94,8 +94,7 @@ onUnmounted(() => {
|
|||
|
||||
watch(() => props.preset, async () => {
|
||||
if (renderer != null) {
|
||||
await renderer.setLayers(props.preset.layers);
|
||||
renderer.render();
|
||||
await renderer.render(props.preset.layers);
|
||||
}
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -124,6 +124,34 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkFolder>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['label', 'frame', 'credit', 'metadata']">
|
||||
<MkFolder>
|
||||
<template #icon><i class="ti ti-device-ipad-horizontal"></i></template>
|
||||
<template #label><SearchLabel>{{ i18n.ts.frame }}</SearchLabel></template>
|
||||
<template #caption>{{ i18n.ts._imageFrameEditor.tip }}</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<div class="_gaps_s">
|
||||
<XImageFrameItem
|
||||
v-for="(preset, i) in prefer.r.imageFramePresets.value"
|
||||
:key="preset.id"
|
||||
:preset="preset"
|
||||
@updatePreset="onUpdateImageFramePreset(preset.id, $event)"
|
||||
@del="onDeleteImageFramePreset(preset.id)"
|
||||
/>
|
||||
|
||||
<MkButton iconOnly rounded style="margin: 0 auto;" @click="addImageFramePreset"><i class="ti ti-plus"></i></MkButton>
|
||||
|
||||
<SearchMarker :keywords="['sync', 'frame', 'label', 'preset', 'devices']">
|
||||
<MkSwitch :modelValue="imageFramePresetsSyncEnabled" @update:modelValue="changeImageFramePresetsSyncEnabled">
|
||||
<template #label><i class="ti ti-cloud-cog"></i> <SearchLabel>{{ i18n.ts.syncBetweenDevices }}</SearchLabel></template>
|
||||
</MkSwitch>
|
||||
</SearchMarker>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['default', 'image', 'compression']">
|
||||
<MkPreferenceContainer k="defaultImageCompressionLevel">
|
||||
<MkSelect
|
||||
|
|
@ -175,7 +203,9 @@ import { computed, defineAsyncComponent, ref } from 'vue';
|
|||
import * as Misskey from 'misskey-js';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import XWatermarkItem from './drive.WatermarkItem.vue';
|
||||
import type { WatermarkPreset } from '@/utility/watermark.js';
|
||||
import XImageFrameItem from './drive.ImageFrameItem.vue';
|
||||
import type { WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
|
||||
import type { ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
|
||||
import FormLink from '@/components/form/link.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
|
|
@ -195,6 +225,7 @@ import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
|
|||
import { selectDriveFolder } from '@/utility/drive.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { genId } from '@/utility/id.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
|
|
@ -236,6 +267,20 @@ function changeWatermarkPresetsSyncEnabled(value: boolean) {
|
|||
}
|
||||
}
|
||||
|
||||
const imageFramePresetsSyncEnabled = ref(prefer.isSyncEnabled('imageFramePresets'));
|
||||
|
||||
function changeImageFramePresetsSyncEnabled(value: boolean) {
|
||||
if (value) {
|
||||
prefer.enableSync('imageFramePresets').then((res) => {
|
||||
if (res == null) return;
|
||||
if (res.enabled) imageFramePresetsSyncEnabled.value = true;
|
||||
});
|
||||
} else {
|
||||
prefer.disableSync('imageFramePresets');
|
||||
imageFramePresetsSyncEnabled.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
misskeyApi('drive').then(info => {
|
||||
capacity.value = info.capacity;
|
||||
usage.value = info.usage;
|
||||
|
|
@ -266,8 +311,11 @@ function chooseUploadFolder() {
|
|||
|
||||
async function addWatermarkPreset() {
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkWatermarkEditorDialog.vue').then(x => x.default), {
|
||||
presetEditMode: true,
|
||||
preset: null,
|
||||
layers: [],
|
||||
}, {
|
||||
ok: (preset: WatermarkPreset) => {
|
||||
presetOk: (preset) => {
|
||||
prefer.commit('watermarkPresets', [...prefer.s.watermarkPresets, preset]);
|
||||
},
|
||||
closed: () => dispose(),
|
||||
|
|
@ -299,6 +347,40 @@ function onDeleteWatermarkPreset(id: string) {
|
|||
}
|
||||
}
|
||||
|
||||
function onUpdateImageFramePreset(id: string, preset: ImageFramePreset) {
|
||||
const index = prefer.s.imageFramePresets.findIndex(p => p.id === id);
|
||||
if (index !== -1) {
|
||||
prefer.commit('imageFramePresets', [
|
||||
...prefer.s.imageFramePresets.slice(0, index),
|
||||
preset,
|
||||
...prefer.s.imageFramePresets.slice(index + 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function onDeleteImageFramePreset(id: string) {
|
||||
const index = prefer.s.imageFramePresets.findIndex(p => p.id === id);
|
||||
if (index !== -1) {
|
||||
prefer.commit('imageFramePresets', [
|
||||
...prefer.s.imageFramePresets.slice(0, index),
|
||||
...prefer.s.imageFramePresets.slice(index + 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async function addImageFramePreset() {
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageFrameEditorDialog.vue').then(x => x.default), {
|
||||
presetEditMode: true,
|
||||
preset: null,
|
||||
params: null,
|
||||
}, {
|
||||
presetOk: (preset) => {
|
||||
prefer.commit('imageFramePresets', [...prefer.s.imageFramePresets, preset]);
|
||||
},
|
||||
closed: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
function saveProfile() {
|
||||
misskeyApi('i/update', {
|
||||
alwaysMarkNsfw: !!alwaysMarkNsfw.value,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import type { SoundType } from '@/utility/sound.js';
|
|||
import type { Plugin } from '@/plugin.js';
|
||||
import type { DeviceKind } from '@/utility/device-kind.js';
|
||||
import type { DeckProfile } from '@/deck.js';
|
||||
import type { WatermarkPreset } from '@/utility/watermark.js';
|
||||
import type { WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
|
||||
import type { ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
|
||||
import { deepEqual } from '@/utility/deep-equal.js';
|
||||
|
|
@ -437,6 +438,26 @@ export const PREF_DEF = definePreferences({
|
|||
accountDependent: true,
|
||||
default: null as WatermarkPreset['id'] | null,
|
||||
},
|
||||
imageFramePresets: {
|
||||
accountDependent: true,
|
||||
default: [] as ImageFramePreset[],
|
||||
mergeStrategy: (a, b) => {
|
||||
const mergedItems = [] as typeof a;
|
||||
for (const x of a.concat(b)) {
|
||||
const sameIdItem = mergedItems.find(y => y.id === x.id);
|
||||
if (sameIdItem != null) {
|
||||
if (deepEqual(x, sameIdItem)) { // 完全な重複は無視
|
||||
continue;
|
||||
} else { // IDは同じなのに内容が違う場合はマージ不可とする
|
||||
throw new Error();
|
||||
}
|
||||
} else {
|
||||
mergedItems.push(x);
|
||||
}
|
||||
}
|
||||
return mergedItems;
|
||||
},
|
||||
},
|
||||
defaultImageCompressionLevel: {
|
||||
default: 2 as 0 | 1 | 2 | 3,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,14 +5,42 @@
|
|||
|
||||
import seedrandom from 'seedrandom';
|
||||
import shader from './blockNoise.glsl';
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export const FX_blockNoise = defineImageEffectorFx({
|
||||
id: 'blockNoise',
|
||||
name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.blockNoise,
|
||||
export const fn = defineImageCompositorFunction<{
|
||||
amount: number;
|
||||
strength: number;
|
||||
width: number;
|
||||
height: number;
|
||||
channelShift: number;
|
||||
seed: number;
|
||||
}>({
|
||||
shader,
|
||||
uniforms: ['amount', 'channelShift'] as const,
|
||||
main: ({ gl, program, u, params }) => {
|
||||
gl.uniform1i(u.amount, params.amount);
|
||||
gl.uniform1f(u.channelShift, params.channelShift);
|
||||
|
||||
const margin = 0;
|
||||
|
||||
const rnd = seedrandom(params.seed.toString());
|
||||
|
||||
for (let i = 0; i < params.amount; i++) {
|
||||
const o = gl.getUniformLocation(program, `u_shiftOrigins[${i.toString()}]`);
|
||||
gl.uniform2f(o, (rnd() * (1 + (margin * 2))) - margin, (rnd() * (1 + (margin * 2))) - margin);
|
||||
|
||||
const s = gl.getUniformLocation(program, `u_shiftStrengths[${i.toString()}]`);
|
||||
gl.uniform1f(s, (1 - (rnd() * 2)) * params.strength);
|
||||
|
||||
const sizes = gl.getUniformLocation(program, `u_shiftSizes[${i.toString()}]`);
|
||||
gl.uniform2f(sizes, params.width, params.height);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const uiDefinition = {
|
||||
name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.blockNoise,
|
||||
params: {
|
||||
amount: {
|
||||
label: i18n.ts._imageEffector._fxProps.amount,
|
||||
|
|
@ -64,23 +92,4 @@ export const FX_blockNoise = defineImageEffectorFx({
|
|||
default: 100,
|
||||
},
|
||||
},
|
||||
main: ({ gl, program, u, params }) => {
|
||||
gl.uniform1i(u.amount, params.amount);
|
||||
gl.uniform1f(u.channelShift, params.channelShift);
|
||||
|
||||
const margin = 0;
|
||||
|
||||
const rnd = seedrandom(params.seed.toString());
|
||||
|
||||
for (let i = 0; i < params.amount; i++) {
|
||||
const o = gl.getUniformLocation(program, `u_shiftOrigins[${i.toString()}]`);
|
||||
gl.uniform2f(o, (rnd() * (1 + (margin * 2))) - margin, (rnd() * (1 + (margin * 2))) - margin);
|
||||
|
||||
const s = gl.getUniformLocation(program, `u_shiftStrengths[${i.toString()}]`);
|
||||
gl.uniform1f(s, (1 - (rnd() * 2)) * params.strength);
|
||||
|
||||
const sizes = gl.getUniformLocation(program, `u_shiftSizes[${i.toString()}]`);
|
||||
gl.uniform2f(sizes, params.width, params.height);
|
||||
}
|
||||
},
|
||||
});
|
||||
} satisfies ImageEffectorUiDefinition<typeof fn>;
|
||||
|
|
@ -3,15 +3,33 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import shader from './blur.glsl';
|
||||
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export const FX_blur = defineImageEffectorFx({
|
||||
id: 'blur',
|
||||
name: i18n.ts._imageEffector._fxs.blur,
|
||||
export const fn = defineImageCompositorFunction<{
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
ellipse: boolean;
|
||||
angle: number;
|
||||
radius: number;
|
||||
}>({
|
||||
shader,
|
||||
uniforms: ['offset', 'scale', 'ellipse', 'angle', 'radius', 'samples'] as const,
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2);
|
||||
gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2);
|
||||
gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0);
|
||||
gl.uniform1f(u.angle, params.angle / 2);
|
||||
gl.uniform1f(u.radius, params.radius);
|
||||
gl.uniform1i(u.samples, 256);
|
||||
},
|
||||
});
|
||||
|
||||
export const uiDefinition = {
|
||||
name: i18n.ts._imageEffector._fxs.blur,
|
||||
params: {
|
||||
offsetX: {
|
||||
label: i18n.ts._imageEffector._fxProps.offset + ' X',
|
||||
|
|
@ -72,12 +90,4 @@ export const FX_blur = defineImageEffectorFx({
|
|||
step: 0.5,
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2);
|
||||
gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2);
|
||||
gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0);
|
||||
gl.uniform1f(u.angle, params.angle / 2);
|
||||
gl.uniform1f(u.radius, params.radius);
|
||||
gl.uniform1i(u.samples, 256);
|
||||
},
|
||||
});
|
||||
} satisfies ImageEffectorUiDefinition<typeof fn>;
|
||||
|
|
@ -3,15 +3,28 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import shader from './checker.glsl';
|
||||
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export const FX_checker = defineImageEffectorFx({
|
||||
id: 'checker',
|
||||
name: i18n.ts._imageEffector._fxs.checker,
|
||||
export const fn = defineImageCompositorFunction<{
|
||||
angle: number;
|
||||
scale: number;
|
||||
color: [number, number, number];
|
||||
opacity: number;
|
||||
}>({
|
||||
shader,
|
||||
uniforms: ['angle', 'scale', 'color', 'opacity'] as const,
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1f(u.angle, params.angle / 2);
|
||||
gl.uniform1f(u.scale, params.scale * params.scale);
|
||||
gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
|
||||
gl.uniform1f(u.opacity, params.opacity);
|
||||
},
|
||||
});
|
||||
|
||||
export const uiDefinition = {
|
||||
name: i18n.ts._imageEffector._fxs.checker,
|
||||
params: {
|
||||
angle: {
|
||||
label: i18n.ts._imageEffector._fxProps.angle,
|
||||
|
|
@ -45,10 +58,4 @@ export const FX_checker = defineImageEffectorFx({
|
|||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1f(u.angle, params.angle / 2);
|
||||
gl.uniform1f(u.scale, params.scale * params.scale);
|
||||
gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
|
||||
gl.uniform1f(u.opacity, params.opacity);
|
||||
},
|
||||
});
|
||||
} satisfies ImageEffectorUiDefinition<typeof fn>;
|
||||
|
|
@ -3,15 +3,24 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import shader from './chromaticAberration.glsl';
|
||||
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export const FX_chromaticAberration = defineImageEffectorFx({
|
||||
id: 'chromaticAberration',
|
||||
name: i18n.ts._imageEffector._fxs.chromaticAberration,
|
||||
export const fn = defineImageCompositorFunction<{
|
||||
normalize: boolean;
|
||||
amount: number;
|
||||
}>({
|
||||
shader,
|
||||
uniforms: ['amount', 'start', 'normalize'] as const,
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1f(u.amount, params.amount);
|
||||
gl.uniform1i(u.normalize, params.normalize ? 1 : 0);
|
||||
},
|
||||
});
|
||||
|
||||
export const uiDefinition = {
|
||||
name: i18n.ts._imageEffector._fxs.chromaticAberration,
|
||||
params: {
|
||||
normalize: {
|
||||
label: i18n.ts._imageEffector._fxProps.normalize,
|
||||
|
|
@ -27,8 +36,4 @@ export const FX_chromaticAberration = defineImageEffectorFx({
|
|||
step: 0.01,
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1f(u.amount, params.amount);
|
||||
gl.uniform1i(u.normalize, params.normalize ? 1 : 0);
|
||||
},
|
||||
});
|
||||
} satisfies ImageEffectorUiDefinition<typeof fn>;
|
||||
|
|
@ -3,15 +3,30 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import shader from './colorAdjust.glsl';
|
||||
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export const FX_colorAdjust = defineImageEffectorFx({
|
||||
id: 'colorAdjust',
|
||||
name: i18n.ts._imageEffector._fxs.colorAdjust,
|
||||
export const fn = defineImageCompositorFunction<{
|
||||
lightness: number;
|
||||
contrast: number;
|
||||
hue: number;
|
||||
brightness: number;
|
||||
saturation: number;
|
||||
}>({
|
||||
shader,
|
||||
uniforms: ['lightness', 'contrast', 'hue', 'brightness', 'saturation'] as const,
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1f(u.brightness, params.brightness);
|
||||
gl.uniform1f(u.contrast, params.contrast);
|
||||
gl.uniform1f(u.hue, params.hue / 2);
|
||||
gl.uniform1f(u.lightness, params.lightness);
|
||||
gl.uniform1f(u.saturation, params.saturation);
|
||||
},
|
||||
});
|
||||
|
||||
export const uiDefinition = {
|
||||
name: i18n.ts._imageEffector._fxs.colorAdjust,
|
||||
params: {
|
||||
lightness: {
|
||||
label: i18n.ts._imageEffector._fxProps.lightness,
|
||||
|
|
@ -59,11 +74,4 @@ export const FX_colorAdjust = defineImageEffectorFx({
|
|||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1f(u.brightness, params.brightness);
|
||||
gl.uniform1f(u.contrast, params.contrast);
|
||||
gl.uniform1f(u.hue, params.hue / 2);
|
||||
gl.uniform1f(u.lightness, params.lightness);
|
||||
gl.uniform1f(u.saturation, params.saturation);
|
||||
},
|
||||
});
|
||||
} satisfies ImageEffectorUiDefinition<typeof fn>;
|
||||
|
|
@ -3,15 +3,28 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import shader from './colorClamp.glsl';
|
||||
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export const FX_colorClamp = defineImageEffectorFx({
|
||||
id: 'colorClamp',
|
||||
name: i18n.ts._imageEffector._fxs.colorClamp,
|
||||
export const fn = defineImageCompositorFunction<{
|
||||
max: number;
|
||||
min: number;
|
||||
}>({
|
||||
shader,
|
||||
uniforms: ['rMax', 'rMin', 'gMax', 'gMin', 'bMax', 'bMin'] as const,
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1f(u.rMax, params.max);
|
||||
gl.uniform1f(u.rMin, 1.0 + params.min);
|
||||
gl.uniform1f(u.gMax, params.max);
|
||||
gl.uniform1f(u.gMin, 1.0 + params.min);
|
||||
gl.uniform1f(u.bMax, params.max);
|
||||
gl.uniform1f(u.bMin, 1.0 + params.min);
|
||||
},
|
||||
});
|
||||
|
||||
export const uiDefinition = {
|
||||
name: i18n.ts._imageEffector._fxs.colorClamp,
|
||||
params: {
|
||||
max: {
|
||||
label: i18n.ts._imageEffector._fxProps.max,
|
||||
|
|
@ -32,12 +45,4 @@ export const FX_colorClamp = defineImageEffectorFx({
|
|||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1f(u.rMax, params.max);
|
||||
gl.uniform1f(u.rMin, 1.0 + params.min);
|
||||
gl.uniform1f(u.gMax, params.max);
|
||||
gl.uniform1f(u.gMin, 1.0 + params.min);
|
||||
gl.uniform1f(u.bMax, params.max);
|
||||
gl.uniform1f(u.bMin, 1.0 + params.min);
|
||||
},
|
||||
});
|
||||
} satisfies ImageEffectorUiDefinition<typeof fn>;
|
||||
|
|
@ -3,15 +3,32 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import shader from './colorClamp.glsl';
|
||||
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export const FX_colorClampAdvanced = defineImageEffectorFx({
|
||||
id: 'colorClampAdvanced',
|
||||
name: i18n.ts._imageEffector._fxs.colorClampAdvanced,
|
||||
export const fn = defineImageCompositorFunction<{
|
||||
rMax: number;
|
||||
rMin: number;
|
||||
gMax: number;
|
||||
gMin: number;
|
||||
bMax: number;
|
||||
bMin: number;
|
||||
}>({
|
||||
shader,
|
||||
uniforms: ['rMax', 'rMin', 'gMax', 'gMin', 'bMax', 'bMin'] as const,
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1f(u.rMax, params.rMax);
|
||||
gl.uniform1f(u.rMin, 1.0 + params.rMin);
|
||||
gl.uniform1f(u.gMax, params.gMax);
|
||||
gl.uniform1f(u.gMin, 1.0 + params.gMin);
|
||||
gl.uniform1f(u.bMax, params.bMax);
|
||||
gl.uniform1f(u.bMin, 1.0 + params.bMin);
|
||||
},
|
||||
});
|
||||
|
||||
export const uiDefinition = {
|
||||
name: i18n.ts._imageEffector._fxs.colorClampAdvanced,
|
||||
params: {
|
||||
rMax: {
|
||||
label: `${i18n.ts._imageEffector._fxProps.max} (${i18n.ts._imageEffector._fxProps.redComponent})`,
|
||||
|
|
@ -68,12 +85,4 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({
|
|||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1f(u.rMax, params.rMax);
|
||||
gl.uniform1f(u.rMin, 1.0 + params.rMin);
|
||||
gl.uniform1f(u.gMax, params.gMax);
|
||||
gl.uniform1f(u.gMin, 1.0 + params.gMin);
|
||||
gl.uniform1f(u.bMax, params.bMax);
|
||||
gl.uniform1f(u.bMin, 1.0 + params.bMin);
|
||||
},
|
||||
});
|
||||
} satisfies ImageEffectorUiDefinition<typeof fn>;
|
||||
|
|
@ -3,15 +3,28 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import shader from './distort.glsl';
|
||||
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export const FX_distort = defineImageEffectorFx({
|
||||
id: 'distort',
|
||||
name: i18n.ts._imageEffector._fxs.distort,
|
||||
export const fn = defineImageCompositorFunction<{
|
||||
direction: number;
|
||||
phase: number;
|
||||
frequency: number;
|
||||
strength: number;
|
||||
}>({
|
||||
shader,
|
||||
uniforms: ['phase', 'frequency', 'strength', 'direction'] as const,
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1f(u.phase, params.phase);
|
||||
gl.uniform1f(u.frequency, params.frequency);
|
||||
gl.uniform1f(u.strength, params.strength);
|
||||
gl.uniform1i(u.direction, params.direction);
|
||||
},
|
||||
});
|
||||
|
||||
export const uiDefinition = {
|
||||
name: i18n.ts._imageEffector._fxs.distort,
|
||||
params: {
|
||||
direction: {
|
||||
label: i18n.ts._imageEffector._fxProps.direction,
|
||||
|
|
@ -49,10 +62,4 @@ export const FX_distort = defineImageEffectorFx({
|
|||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1f(u.phase, params.phase);
|
||||
gl.uniform1f(u.frequency, params.frequency);
|
||||
gl.uniform1f(u.strength, params.strength);
|
||||
gl.uniform1i(u.direction, params.direction);
|
||||
},
|
||||
});
|
||||
} satisfies ImageEffectorUiDefinition<typeof fn>;
|
||||
|
|
@ -3,15 +3,34 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import shader from './fill.glsl';
|
||||
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export const FX_fill = defineImageEffectorFx({
|
||||
id: 'fill',
|
||||
name: i18n.ts._imageEffector._fxs.fill,
|
||||
export const fn = defineImageCompositorFunction<{
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
ellipse: boolean;
|
||||
angle: number;
|
||||
color: [number, number, number];
|
||||
opacity: number;
|
||||
}>({
|
||||
shader,
|
||||
uniforms: ['offset', 'scale', 'ellipse', 'angle', 'color', 'opacity'] as const,
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2);
|
||||
gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2);
|
||||
gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0);
|
||||
gl.uniform1f(u.angle, params.angle / 2);
|
||||
gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
|
||||
gl.uniform1f(u.opacity, params.opacity);
|
||||
},
|
||||
});
|
||||
|
||||
export const uiDefinition = {
|
||||
name: i18n.ts._imageEffector._fxs.fill,
|
||||
params: {
|
||||
offsetX: {
|
||||
label: i18n.ts._imageEffector._fxProps.offset + ' X',
|
||||
|
|
@ -78,12 +97,4 @@ export const FX_fill = defineImageEffectorFx({
|
|||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2);
|
||||
gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2);
|
||||
gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0);
|
||||
gl.uniform1f(u.angle, params.angle / 2);
|
||||
gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
|
||||
gl.uniform1f(u.opacity, params.opacity);
|
||||
},
|
||||
});
|
||||
} satisfies ImageEffectorUiDefinition<typeof fn>;
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import shader from './grayscale.glsl';
|
||||
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export const fn = defineImageCompositorFunction({
|
||||
shader,
|
||||
main: ({ gl, u, params }) => {
|
||||
},
|
||||
});
|
||||
|
||||
export const uiDefinition = {
|
||||
name: i18n.ts._imageEffector._fxs.grayscale,
|
||||
params: {
|
||||
},
|
||||
} satisfies ImageEffectorUiDefinition<typeof fn>;
|
||||
|
|
@ -3,15 +3,26 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import shader from './invert.glsl';
|
||||
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export const FX_invert = defineImageEffectorFx({
|
||||
id: 'invert',
|
||||
name: i18n.ts._imageEffector._fxs.invert,
|
||||
export const fn = defineImageCompositorFunction<{
|
||||
r: boolean;
|
||||
g: boolean;
|
||||
b: boolean;
|
||||
}>({
|
||||
shader,
|
||||
uniforms: ['r', 'g', 'b'] as const,
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1i(u.r, params.r ? 1 : 0);
|
||||
gl.uniform1i(u.g, params.g ? 1 : 0);
|
||||
gl.uniform1i(u.b, params.b ? 1 : 0);
|
||||
},
|
||||
});
|
||||
|
||||
export const uiDefinition = {
|
||||
name: i18n.ts._imageEffector._fxs.invert,
|
||||
params: {
|
||||
r: {
|
||||
label: i18n.ts._imageEffector._fxProps.redComponent,
|
||||
|
|
@ -29,9 +40,4 @@ export const FX_invert = defineImageEffectorFx({
|
|||
default: true,
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1i(u.r, params.r ? 1 : 0);
|
||||
gl.uniform1i(u.g, params.g ? 1 : 0);
|
||||
gl.uniform1i(u.b, params.b ? 1 : 0);
|
||||
},
|
||||
});
|
||||
} satisfies ImageEffectorUiDefinition<typeof fn>;
|
||||
|
|
@ -3,15 +3,24 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import shader from './mirror.glsl';
|
||||
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export const FX_mirror = defineImageEffectorFx({
|
||||
id: 'mirror',
|
||||
name: i18n.ts._imageEffector._fxs.mirror,
|
||||
export const fn = defineImageCompositorFunction<{
|
||||
h: number;
|
||||
v: number;
|
||||
}>({
|
||||
shader,
|
||||
uniforms: ['h', 'v'] as const,
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1i(u.h, params.h);
|
||||
gl.uniform1i(u.v, params.v);
|
||||
},
|
||||
});
|
||||
|
||||
export const uiDefinition = {
|
||||
name: i18n.ts._imageEffector._fxs.mirror,
|
||||
params: {
|
||||
h: {
|
||||
label: i18n.ts.horizontal,
|
||||
|
|
@ -19,7 +28,7 @@ export const FX_mirror = defineImageEffectorFx({
|
|||
enum: [
|
||||
{ value: -1 as const, icon: 'ti ti-arrow-bar-right' },
|
||||
{ value: 0 as const, icon: 'ti ti-minus-vertical' },
|
||||
{ value: 1 as const, icon: 'ti ti-arrow-bar-left' }
|
||||
{ value: 1 as const, icon: 'ti ti-arrow-bar-left' },
|
||||
],
|
||||
default: -1,
|
||||
},
|
||||
|
|
@ -29,13 +38,9 @@ export const FX_mirror = defineImageEffectorFx({
|
|||
enum: [
|
||||
{ value: -1 as const, icon: 'ti ti-arrow-bar-down' },
|
||||
{ value: 0 as const, icon: 'ti ti-minus' },
|
||||
{ value: 1 as const, icon: 'ti ti-arrow-bar-up' }
|
||||
{ value: 1 as const, icon: 'ti ti-arrow-bar-up' },
|
||||
],
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1i(u.h, params.h);
|
||||
gl.uniform1i(u.v, params.v);
|
||||
},
|
||||
});
|
||||
} satisfies ImageEffectorUiDefinition<typeof fn>;
|
||||
|
|
@ -3,15 +3,33 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import shader from './pixelate.glsl';
|
||||
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export const FX_pixelate = defineImageEffectorFx({
|
||||
id: 'pixelate',
|
||||
name: i18n.ts._imageEffector._fxs.pixelate,
|
||||
export const fn = defineImageCompositorFunction<{
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
ellipse: boolean;
|
||||
angle: number;
|
||||
strength: number;
|
||||
}>({
|
||||
shader,
|
||||
uniforms: ['offset', 'scale', 'ellipse', 'angle', 'strength', 'samples'] as const,
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2);
|
||||
gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2);
|
||||
gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0);
|
||||
gl.uniform1f(u.angle, params.angle / 2);
|
||||
gl.uniform1f(u.strength, params.strength * params.strength);
|
||||
gl.uniform1i(u.samples, 256);
|
||||
},
|
||||
});
|
||||
|
||||
export const uiDefinition = {
|
||||
name: i18n.ts._imageEffector._fxs.pixelate,
|
||||
params: {
|
||||
offsetX: {
|
||||
label: i18n.ts._imageEffector._fxProps.offset + ' X',
|
||||
|
|
@ -72,12 +90,4 @@ export const FX_pixelate = defineImageEffectorFx({
|
|||
step: 0.01,
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2);
|
||||
gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2);
|
||||
gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0);
|
||||
gl.uniform1f(u.angle, params.angle / 2);
|
||||
gl.uniform1f(u.strength, params.strength * params.strength);
|
||||
gl.uniform1i(u.samples, 256);
|
||||
},
|
||||
});
|
||||
} satisfies ImageEffectorUiDefinition<typeof fn>;
|
||||
|
|
@ -3,16 +3,36 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import shader from './polkadot.glsl';
|
||||
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
// Primarily used for watermark
|
||||
export const FX_polkadot = defineImageEffectorFx({
|
||||
id: 'polkadot',
|
||||
name: i18n.ts._imageEffector._fxs.polkadot,
|
||||
export const fn = defineImageCompositorFunction<{
|
||||
angle: number;
|
||||
scale: number;
|
||||
majorRadius: number;
|
||||
majorOpacity: number;
|
||||
minorDivisions: number;
|
||||
minorRadius: number;
|
||||
minorOpacity: number;
|
||||
color: [number, number, number];
|
||||
}>({
|
||||
shader,
|
||||
uniforms: ['angle', 'scale', 'major_radius', 'major_opacity', 'minor_divisions', 'minor_radius', 'minor_opacity', 'color'] as const,
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1f(u.angle, params.angle / 2);
|
||||
gl.uniform1f(u.scale, params.scale * params.scale);
|
||||
gl.uniform1f(u.major_radius, params.majorRadius);
|
||||
gl.uniform1f(u.major_opacity, params.majorOpacity);
|
||||
gl.uniform1f(u.minor_divisions, params.minorDivisions);
|
||||
gl.uniform1f(u.minor_radius, params.minorRadius);
|
||||
gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
|
||||
gl.uniform1f(u.minor_opacity, params.minorOpacity);
|
||||
},
|
||||
});
|
||||
|
||||
export const uiDefinition = {
|
||||
name: i18n.ts._imageEffector._fxs.polkadot,
|
||||
params: {
|
||||
angle: {
|
||||
label: i18n.ts._imageEffector._fxProps.angle,
|
||||
|
|
@ -79,14 +99,4 @@ export const FX_polkadot = defineImageEffectorFx({
|
|||
default: [1, 1, 1],
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1f(u.angle, params.angle / 2);
|
||||
gl.uniform1f(u.scale, params.scale * params.scale);
|
||||
gl.uniform1f(u.major_radius, params.majorRadius);
|
||||
gl.uniform1f(u.major_opacity, params.majorOpacity);
|
||||
gl.uniform1f(u.minor_divisions, params.minorDivisions);
|
||||
gl.uniform1f(u.minor_radius, params.minorRadius);
|
||||
gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
|
||||
gl.uniform1f(u.minor_opacity, params.minorOpacity);
|
||||
},
|
||||
});
|
||||
} satisfies ImageEffectorUiDefinition<typeof fn>;
|
||||
|
|
@ -3,16 +3,31 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import shader from './stripe.glsl';
|
||||
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
// Primarily used for watermark
|
||||
export const FX_stripe = defineImageEffectorFx({
|
||||
id: 'stripe',
|
||||
name: i18n.ts._imageEffector._fxs.stripe,
|
||||
export const fn = defineImageCompositorFunction<{
|
||||
angle: number;
|
||||
frequency: number;
|
||||
threshold: number;
|
||||
color: [number, number, number];
|
||||
opacity: number;
|
||||
}>({
|
||||
shader,
|
||||
uniforms: ['angle', 'frequency', 'phase', 'threshold', 'color', 'opacity'] as const,
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1f(u.angle, params.angle / 2);
|
||||
gl.uniform1f(u.frequency, params.frequency * params.frequency);
|
||||
gl.uniform1f(u.phase, 0.0);
|
||||
gl.uniform1f(u.threshold, params.threshold);
|
||||
gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
|
||||
gl.uniform1f(u.opacity, params.opacity);
|
||||
},
|
||||
});
|
||||
|
||||
export const uiDefinition = {
|
||||
name: i18n.ts._imageEffector._fxs.stripe,
|
||||
params: {
|
||||
angle: {
|
||||
label: i18n.ts._imageEffector._fxProps.angle,
|
||||
|
|
@ -55,12 +70,4 @@ export const FX_stripe = defineImageEffectorFx({
|
|||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1f(u.angle, params.angle / 2);
|
||||
gl.uniform1f(u.frequency, params.frequency * params.frequency);
|
||||
gl.uniform1f(u.phase, 0.0);
|
||||
gl.uniform1f(u.threshold, params.threshold);
|
||||
gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
|
||||
gl.uniform1f(u.opacity, params.opacity);
|
||||
},
|
||||
});
|
||||
} satisfies ImageEffectorUiDefinition<typeof fn>;
|
||||
|
|
@ -5,14 +5,39 @@
|
|||
|
||||
import seedrandom from 'seedrandom';
|
||||
import shader from './tearing.glsl';
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export const FX_tearing = defineImageEffectorFx({
|
||||
id: 'tearing',
|
||||
name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.tearing,
|
||||
export const fn = defineImageCompositorFunction<{
|
||||
amount: number;
|
||||
strength: number;
|
||||
size: number;
|
||||
channelShift: number;
|
||||
seed: number;
|
||||
}>({
|
||||
shader,
|
||||
uniforms: ['amount', 'channelShift'] as const,
|
||||
main: ({ gl, program, u, params }) => {
|
||||
gl.uniform1i(u.amount, params.amount);
|
||||
gl.uniform1f(u.channelShift, params.channelShift);
|
||||
|
||||
const rnd = seedrandom(params.seed.toString());
|
||||
|
||||
for (let i = 0; i < params.amount; i++) {
|
||||
const o = gl.getUniformLocation(program, `u_shiftOrigins[${i.toString()}]`);
|
||||
gl.uniform1f(o, rnd());
|
||||
|
||||
const s = gl.getUniformLocation(program, `u_shiftStrengths[${i.toString()}]`);
|
||||
gl.uniform1f(s, (1 - (rnd() * 2)) * params.strength);
|
||||
|
||||
const h = gl.getUniformLocation(program, `u_shiftHeights[${i.toString()}]`);
|
||||
gl.uniform1f(h, rnd() * params.size);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const uiDefinition = {
|
||||
name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.tearing,
|
||||
params: {
|
||||
amount: {
|
||||
label: i18n.ts._imageEffector._fxProps.amount,
|
||||
|
|
@ -55,21 +80,4 @@ export const FX_tearing = defineImageEffectorFx({
|
|||
default: 100,
|
||||
},
|
||||
},
|
||||
main: ({ gl, program, u, params }) => {
|
||||
gl.uniform1i(u.amount, params.amount);
|
||||
gl.uniform1f(u.channelShift, params.channelShift);
|
||||
|
||||
const rnd = seedrandom(params.seed.toString());
|
||||
|
||||
for (let i = 0; i < params.amount; i++) {
|
||||
const o = gl.getUniformLocation(program, `u_shiftOrigins[${i.toString()}]`);
|
||||
gl.uniform1f(o, rnd());
|
||||
|
||||
const s = gl.getUniformLocation(program, `u_shiftStrengths[${i.toString()}]`);
|
||||
gl.uniform1f(s, (1 - (rnd() * 2)) * params.strength);
|
||||
|
||||
const h = gl.getUniformLocation(program, `u_shiftHeights[${i.toString()}]`);
|
||||
gl.uniform1f(h, rnd() * params.size);
|
||||
}
|
||||
},
|
||||
});
|
||||
} satisfies ImageEffectorUiDefinition<typeof fn>;
|
||||
|
|
@ -3,15 +3,26 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import shader from './threshold.glsl';
|
||||
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export const FX_threshold = defineImageEffectorFx({
|
||||
id: 'threshold',
|
||||
name: i18n.ts._imageEffector._fxs.threshold,
|
||||
export const fn = defineImageCompositorFunction<{
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}>({
|
||||
shader,
|
||||
uniforms: ['r', 'g', 'b'] as const,
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1f(u.r, params.r);
|
||||
gl.uniform1f(u.g, params.g);
|
||||
gl.uniform1f(u.b, params.b);
|
||||
},
|
||||
});
|
||||
|
||||
export const uiDefinition = {
|
||||
name: i18n.ts._imageEffector._fxs.threshold,
|
||||
params: {
|
||||
r: {
|
||||
label: i18n.ts._imageEffector._fxProps.redComponent,
|
||||
|
|
@ -38,9 +49,4 @@ export const FX_threshold = defineImageEffectorFx({
|
|||
step: 0.01,
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform1f(u.r, params.r);
|
||||
gl.uniform1f(u.g, params.g);
|
||||
gl.uniform1f(u.b, params.b);
|
||||
},
|
||||
});
|
||||
} satisfies ImageEffectorUiDefinition<typeof fn>;
|
||||
|
|
@ -3,15 +3,34 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import shader from './zoomLines.glsl';
|
||||
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export const FX_zoomLines = defineImageEffectorFx({
|
||||
id: 'zoomLines',
|
||||
name: i18n.ts._imageEffector._fxs.zoomLines,
|
||||
export const fn = defineImageCompositorFunction<{
|
||||
x: number;
|
||||
y: number;
|
||||
frequency: number;
|
||||
smoothing: boolean;
|
||||
threshold: number;
|
||||
maskSize: number;
|
||||
black: boolean;
|
||||
}>({
|
||||
shader,
|
||||
uniforms: ['pos', 'frequency', 'thresholdEnabled', 'threshold', 'maskSize', 'black'] as const,
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform2f(u.pos, params.x / 2, params.y / 2);
|
||||
gl.uniform1f(u.frequency, params.frequency * params.frequency);
|
||||
// thresholdの調整が有効な間はsmoothingが利用できない
|
||||
gl.uniform1i(u.thresholdEnabled, params.smoothing ? 0 : 1);
|
||||
gl.uniform1f(u.threshold, params.threshold);
|
||||
gl.uniform1f(u.maskSize, params.maskSize);
|
||||
gl.uniform1i(u.black, params.black ? 1 : 0);
|
||||
},
|
||||
});
|
||||
|
||||
export const uiDefinition = {
|
||||
name: i18n.ts._imageEffector._fxs.zoomLines,
|
||||
params: {
|
||||
x: {
|
||||
label: i18n.ts._imageEffector._fxProps.centerX,
|
||||
|
|
@ -65,13 +84,4 @@ export const FX_zoomLines = defineImageEffectorFx({
|
|||
default: false,
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform2f(u.pos, params.x / 2, params.y / 2);
|
||||
gl.uniform1f(u.frequency, params.frequency * params.frequency);
|
||||
// thresholdの調整が有効な間はsmoothingが利用できない
|
||||
gl.uniform1i(u.thresholdEnabled, params.smoothing ? 0 : 1);
|
||||
gl.uniform1f(u.threshold, params.threshold);
|
||||
gl.uniform1f(u.maskSize, params.maskSize);
|
||||
gl.uniform1i(u.black, params.black ? 1 : 0);
|
||||
},
|
||||
});
|
||||
} satisfies ImageEffectorUiDefinition<typeof fn>;
|
||||
|
|
@ -3,18 +3,12 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import QRCodeStyling from 'qr-code-styling';
|
||||
import { url, host } from '@@/js/config.js';
|
||||
import { getProxiedImageUrl } from '../media-proxy.js';
|
||||
import { initShaderProgram } from '../webgl.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import { FXS } from './fxs.js';
|
||||
import type { ImageCompositorFunction, ImageCompositorLayer } from '@/lib/ImageCompositor.js';
|
||||
import { ImageCompositor } from '@/lib/ImageCompositor.js';
|
||||
|
||||
export type ImageEffectorRGB = [r: number, g: number, b: number];
|
||||
|
||||
type ParamTypeToPrimitive = {
|
||||
[K in ImageEffectorFxParamDef['type']]: (ImageEffectorFxParamDef & { type: K })['default'];
|
||||
};
|
||||
|
||||
interface CommonParamDef {
|
||||
type: string;
|
||||
label?: string;
|
||||
|
|
@ -60,479 +54,77 @@ interface SeedParamDef extends CommonParamDef {
|
|||
default: number;
|
||||
};
|
||||
|
||||
interface TextureParamDef extends CommonParamDef {
|
||||
type: 'texture';
|
||||
default: {
|
||||
type: 'text'; text: string | null;
|
||||
} | {
|
||||
type: 'url'; url: string | null;
|
||||
} | {
|
||||
type: 'qr'; data: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
interface ColorParamDef extends CommonParamDef {
|
||||
type: 'color';
|
||||
default: ImageEffectorRGB;
|
||||
};
|
||||
|
||||
type ImageEffectorFxParamDef = NumberParamDef | NumberEnumParamDef | BooleanParamDef | AlignParamDef | SeedParamDef | TextureParamDef | ColorParamDef;
|
||||
type ImageEffectorFxParamDef = NumberParamDef | NumberEnumParamDef | BooleanParamDef | AlignParamDef | SeedParamDef | ColorParamDef;
|
||||
|
||||
export type ImageEffectorFxParamDefs = Record<string, ImageEffectorFxParamDef>;
|
||||
|
||||
export type GetParamType<T extends ImageEffectorFxParamDef> =
|
||||
T extends NumberEnumParamDef
|
||||
? T['enum'][number]['value']
|
||||
: ParamTypeToPrimitive[T['type']];
|
||||
|
||||
export type ParamsRecordTypeToDefRecord<PS extends ImageEffectorFxParamDefs> = {
|
||||
[K in keyof PS]: GetParamType<PS[K]>;
|
||||
};
|
||||
|
||||
export function defineImageEffectorFx<ID extends string, PS extends ImageEffectorFxParamDefs, US extends string[]>(fx: ImageEffectorFx<ID, PS, US>) {
|
||||
return fx;
|
||||
}
|
||||
|
||||
export type ImageEffectorFx<ID extends string = string, PS extends ImageEffectorFxParamDefs = ImageEffectorFxParamDefs, US extends string[] = string[]> = {
|
||||
id: ID;
|
||||
name: string;
|
||||
shader: string;
|
||||
uniforms: US;
|
||||
params: PS,
|
||||
main: (ctx: {
|
||||
gl: WebGL2RenderingContext;
|
||||
program: WebGLProgram;
|
||||
params: ParamsRecordTypeToDefRecord<PS>;
|
||||
u: Record<US[number], WebGLUniformLocation>;
|
||||
width: number;
|
||||
height: number;
|
||||
textures: Record<string, {
|
||||
texture: WebGLTexture;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export type ImageEffectorLayer = {
|
||||
id: string;
|
||||
fxId: string;
|
||||
params: Record<string, any>;
|
||||
[K in keyof typeof FXS]: {
|
||||
id: string;
|
||||
fxId: K;
|
||||
params: Parameters<(typeof FXS)[K]['fn']['main']>[0]['params'];
|
||||
};
|
||||
}[keyof typeof FXS];
|
||||
|
||||
export type ImageEffectorUiDefinition<Fn extends ImageCompositorFunction<any> = ImageCompositorFunction> = {
|
||||
name: string;
|
||||
params: Fn extends ImageCompositorFunction<infer P> ? {
|
||||
[K in keyof P]: ImageEffectorFxParamDef;
|
||||
} : never;
|
||||
};
|
||||
|
||||
function getValue<T extends keyof ParamTypeToPrimitive>(params: Record<string, any>, k: string): ParamTypeToPrimitive[T] {
|
||||
return params[k];
|
||||
}
|
||||
type ImageEffectorImageCompositor = ImageCompositor<{
|
||||
[K in keyof typeof FXS]: typeof FXS[K]['fn'];
|
||||
}>;
|
||||
|
||||
export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, any>>> {
|
||||
private gl: WebGL2RenderingContext;
|
||||
export class ImageEffector {
|
||||
private canvas: HTMLCanvasElement | null = null;
|
||||
private renderWidth: number;
|
||||
private renderHeight: number;
|
||||
private originalImage: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
|
||||
private layers: ImageEffectorLayer[] = [];
|
||||
private originalImageTexture: WebGLTexture;
|
||||
private shaderCache: Map<string, WebGLProgram> = new Map();
|
||||
private perLayerResultTextures: Map<string, WebGLTexture> = new Map();
|
||||
private perLayerResultFrameBuffers: Map<string, WebGLFramebuffer> = new Map();
|
||||
private nopProgram: WebGLProgram;
|
||||
private fxs: [...IEX];
|
||||
private paramTextures: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map();
|
||||
private compositor: ImageEffectorImageCompositor;
|
||||
|
||||
constructor(options: {
|
||||
canvas: HTMLCanvasElement;
|
||||
renderWidth: number;
|
||||
renderHeight: number;
|
||||
image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
|
||||
fxs: [...IEX];
|
||||
image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement | null;
|
||||
}) {
|
||||
this.canvas = options.canvas;
|
||||
this.renderWidth = options.renderWidth;
|
||||
this.renderHeight = options.renderHeight;
|
||||
this.originalImage = options.image;
|
||||
this.fxs = options.fxs;
|
||||
|
||||
this.canvas.width = this.renderWidth;
|
||||
this.canvas.height = this.renderHeight;
|
||||
|
||||
const gl = this.canvas.getContext('webgl2', {
|
||||
preserveDrawingBuffer: false,
|
||||
alpha: true,
|
||||
premultipliedAlpha: false,
|
||||
this.compositor = new ImageCompositor({
|
||||
canvas: this.canvas,
|
||||
renderWidth: options.renderWidth,
|
||||
renderHeight: options.renderHeight,
|
||||
image: options.image,
|
||||
functions: Object.fromEntries(Object.entries(FXS).map(([fxId, fx]) => [fxId, fx.fn])),
|
||||
});
|
||||
|
||||
if (gl == null) {
|
||||
throw new Error('Failed to initialize WebGL2 context');
|
||||
}
|
||||
|
||||
this.gl = gl;
|
||||
|
||||
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
|
||||
|
||||
const VERTICES = new Float32Array([-1, -1, -1, 1, 1, 1, -1, -1, 1, 1, 1, -1]);
|
||||
const vertexBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
|
||||
|
||||
this.originalImageTexture = createTexture(gl);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.originalImage.width, this.originalImage.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, this.originalImage);
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
|
||||
this.nopProgram = initShaderProgram(this.gl, `#version 300 es
|
||||
in vec2 position;
|
||||
out vec2 in_uv;
|
||||
|
||||
void main() {
|
||||
in_uv = (position + 1.0) / 2.0;
|
||||
gl_Position = vec4(position * vec2(1.0, -1.0), 0.0, 1.0);
|
||||
}
|
||||
`, `#version 300 es
|
||||
precision mediump float;
|
||||
|
||||
in vec2 in_uv;
|
||||
uniform sampler2D u_texture;
|
||||
out vec4 out_color;
|
||||
|
||||
void main() {
|
||||
out_color = texture(u_texture, in_uv);
|
||||
}
|
||||
`);
|
||||
|
||||
// レジスタ番号はシェーダープログラムに属しているわけではなく、独立の存在なので、とりあえず nopProgram を使って設定する(その後は効果が持続する)
|
||||
// ref. https://qiita.com/emadurandal/items/5966c8374f03d4de3266
|
||||
const positionLocation = gl.getAttribLocation(this.nopProgram, 'position');
|
||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.enableVertexAttribArray(positionLocation);
|
||||
}
|
||||
|
||||
private renderLayer(layer: ImageEffectorLayer, preTexture: WebGLTexture, invert = false) {
|
||||
const gl = this.gl;
|
||||
|
||||
const fx = this.fxs.find(fx => fx.id === layer.fxId);
|
||||
if (fx == null) return;
|
||||
|
||||
const cachedShader = this.shaderCache.get(fx.id);
|
||||
const shaderProgram = cachedShader ?? initShaderProgram(this.gl, `#version 300 es
|
||||
in vec2 position;
|
||||
uniform bool u_invert;
|
||||
out vec2 in_uv;
|
||||
|
||||
void main() {
|
||||
in_uv = (position + 1.0) / 2.0;
|
||||
gl_Position = u_invert ? vec4(position * vec2(1.0, -1.0), 0.0, 1.0) : vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`, fx.shader);
|
||||
if (cachedShader == null) {
|
||||
this.shaderCache.set(fx.id, shaderProgram);
|
||||
}
|
||||
|
||||
gl.useProgram(shaderProgram);
|
||||
|
||||
const in_resolution = gl.getUniformLocation(shaderProgram, 'in_resolution');
|
||||
gl.uniform2fv(in_resolution, [this.renderWidth, this.renderHeight]);
|
||||
|
||||
const u_invert = gl.getUniformLocation(shaderProgram, 'u_invert');
|
||||
gl.uniform1i(u_invert, invert ? 1 : 0);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, preTexture);
|
||||
const in_texture = gl.getUniformLocation(shaderProgram, 'in_texture');
|
||||
gl.uniform1i(in_texture, 0);
|
||||
|
||||
fx.main({
|
||||
gl: gl,
|
||||
program: shaderProgram,
|
||||
params: Object.fromEntries(
|
||||
Object.entries(fx.params as ImageEffectorFxParamDefs).map(([key, param]) => {
|
||||
return [key, layer.params[key] ?? param.default];
|
||||
}),
|
||||
),
|
||||
u: Object.fromEntries(fx.uniforms.map(u => [u, gl.getUniformLocation(shaderProgram, 'u_' + u)!])),
|
||||
width: this.renderWidth,
|
||||
height: this.renderHeight,
|
||||
textures: Object.fromEntries(
|
||||
Object.entries(fx.params as ImageEffectorFxParamDefs).map(([k, v]) => {
|
||||
if (v.type !== 'texture') return [k, null];
|
||||
const param = getValue<typeof v.type>(layer.params, k);
|
||||
if (param == null) return [k, null];
|
||||
const texture = this.paramTextures.get(this.getTextureKeyForParam(param)) ?? null;
|
||||
return [k, texture];
|
||||
})),
|
||||
});
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const gl = this.gl;
|
||||
|
||||
// 入力をそのまま出力
|
||||
if (this.layers.length === 0) {
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture);
|
||||
|
||||
gl.useProgram(this.nopProgram);
|
||||
gl.uniform1i(gl.getUniformLocation(this.nopProgram, 'u_texture')!, 0);
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
return;
|
||||
}
|
||||
|
||||
let preTexture = this.originalImageTexture;
|
||||
|
||||
for (const layer of this.layers) {
|
||||
const isLast = layer === this.layers.at(-1);
|
||||
|
||||
const cachedResultTexture = this.perLayerResultTextures.get(layer.id);
|
||||
const resultTexture = cachedResultTexture ?? createTexture(gl);
|
||||
if (cachedResultTexture == null) {
|
||||
this.perLayerResultTextures.set(layer.id, resultTexture);
|
||||
}
|
||||
gl.bindTexture(gl.TEXTURE_2D, resultTexture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.renderWidth, this.renderHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
|
||||
if (isLast) {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
} else {
|
||||
const cachedResultFrameBuffer = this.perLayerResultFrameBuffers.get(layer.id);
|
||||
const resultFrameBuffer = cachedResultFrameBuffer ?? gl.createFramebuffer()!;
|
||||
if (cachedResultFrameBuffer == null) {
|
||||
this.perLayerResultFrameBuffers.set(layer.id, resultFrameBuffer);
|
||||
}
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, resultFrameBuffer);
|
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, resultTexture, 0);
|
||||
}
|
||||
|
||||
this.renderLayer(layer, preTexture, isLast);
|
||||
|
||||
preTexture = resultTexture;
|
||||
}
|
||||
}
|
||||
|
||||
public async setLayers(layers: ImageEffectorLayer[]) {
|
||||
this.layers = layers;
|
||||
|
||||
const unused = new Set(this.paramTextures.keys());
|
||||
public async render(layers: ImageEffectorLayer[]) {
|
||||
const compositorLayers: Parameters<ImageCompositor<any>['render']>[0] = [];
|
||||
|
||||
for (const layer of layers) {
|
||||
const fx = this.fxs.find(fx => fx.id === layer.fxId);
|
||||
if (fx == null) continue;
|
||||
|
||||
for (const k of Object.keys(layer.params)) {
|
||||
const paramDef = fx.params[k];
|
||||
if (paramDef == null) continue;
|
||||
if (paramDef.type !== 'texture') continue;
|
||||
const v = getValue<typeof paramDef.type>(layer.params, k);
|
||||
if (v == null) continue;
|
||||
|
||||
const textureKey = this.getTextureKeyForParam(v);
|
||||
unused.delete(textureKey);
|
||||
if (this.paramTextures.has(textureKey)) continue;
|
||||
|
||||
if (_DEV_) console.log(`Baking texture of <${textureKey}>...`);
|
||||
|
||||
const texture =
|
||||
v.type === 'text' ? await createTextureFromText(this.gl, v.text) :
|
||||
v.type === 'url' ? await createTextureFromUrl(this.gl, v.url) :
|
||||
v.type === 'qr' ? await createTextureFromQr(this.gl, { data: v.data }) :
|
||||
null;
|
||||
if (texture == null) continue;
|
||||
|
||||
this.paramTextures.set(textureKey, texture);
|
||||
}
|
||||
compositorLayers.push({
|
||||
id: layer.id,
|
||||
functionId: layer.fxId,
|
||||
params: layer.params,
|
||||
});
|
||||
}
|
||||
|
||||
for (const k of unused) {
|
||||
if (_DEV_) console.log(`Dispose unused texture <${k}>...`);
|
||||
this.gl.deleteTexture(this.paramTextures.get(k)!.texture);
|
||||
this.paramTextures.delete(k);
|
||||
}
|
||||
|
||||
this.render();
|
||||
this.compositor.render(compositorLayers as Parameters<ImageEffectorImageCompositor['render']>[0]);
|
||||
}
|
||||
|
||||
public changeResolution(width: number, height: number) {
|
||||
this.renderWidth = width;
|
||||
this.renderHeight = height;
|
||||
if (this.canvas) {
|
||||
this.canvas.width = this.renderWidth;
|
||||
this.canvas.height = this.renderHeight;
|
||||
}
|
||||
this.gl.viewport(0, 0, this.renderWidth, this.renderHeight);
|
||||
}
|
||||
|
||||
private getTextureKeyForParam(v: ParamTypeToPrimitive['texture']) {
|
||||
if (v == null) return '';
|
||||
return (
|
||||
v.type === 'text' ? `text:${v.text}` :
|
||||
v.type === 'url' ? `url:${v.url}` :
|
||||
v.type === 'qr' ? `qr:${v.data}` :
|
||||
''
|
||||
);
|
||||
this.compositor.changeResolution(width, height);
|
||||
}
|
||||
|
||||
/*
|
||||
* disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意
|
||||
*/
|
||||
public destroy(disposeCanvas = true) {
|
||||
this.gl.deleteProgram(this.nopProgram);
|
||||
|
||||
for (const shader of this.shaderCache.values()) {
|
||||
this.gl.deleteProgram(shader);
|
||||
}
|
||||
this.shaderCache.clear();
|
||||
|
||||
for (const texture of this.perLayerResultTextures.values()) {
|
||||
this.gl.deleteTexture(texture);
|
||||
}
|
||||
this.perLayerResultTextures.clear();
|
||||
|
||||
for (const framebuffer of this.perLayerResultFrameBuffers.values()) {
|
||||
this.gl.deleteFramebuffer(framebuffer);
|
||||
}
|
||||
this.perLayerResultFrameBuffers.clear();
|
||||
|
||||
for (const texture of this.paramTextures.values()) {
|
||||
this.gl.deleteTexture(texture.texture);
|
||||
}
|
||||
this.paramTextures.clear();
|
||||
|
||||
this.gl.deleteTexture(this.originalImageTexture);
|
||||
|
||||
if (disposeCanvas) {
|
||||
const loseContextExt = this.gl.getExtension('WEBGL_lose_context');
|
||||
if (loseContextExt) loseContextExt.loseContext();
|
||||
}
|
||||
this.compositor.destroy(disposeCanvas);
|
||||
}
|
||||
}
|
||||
|
||||
function createTexture(gl: WebGL2RenderingContext): WebGLTexture {
|
||||
const texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
return texture;
|
||||
}
|
||||
|
||||
async function createTextureFromUrl(gl: WebGL2RenderingContext, imageUrl: string | null): Promise<{ texture: WebGLTexture, width: number, height: number } | null> {
|
||||
if (imageUrl == null || imageUrl.trim() === '') return null;
|
||||
|
||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = getProxiedImageUrl(imageUrl); // CORS対策
|
||||
}).catch(() => null);
|
||||
|
||||
if (image == null) return null;
|
||||
|
||||
const texture = createTexture(gl);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, image.width, image.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
|
||||
return {
|
||||
texture,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
};
|
||||
}
|
||||
|
||||
async function createTextureFromText(gl: WebGL2RenderingContext, text: string | null, resolution = 2048): Promise<{ texture: WebGLTexture, width: number, height: number } | null> {
|
||||
if (text == null || text.trim() === '') return null;
|
||||
|
||||
const ctx = window.document.createElement('canvas').getContext('2d')!;
|
||||
ctx.canvas.width = resolution;
|
||||
ctx.canvas.height = resolution / 4;
|
||||
const fontSize = resolution / 32;
|
||||
const margin = fontSize / 2;
|
||||
ctx.shadowColor = '#000000';
|
||||
ctx.shadowBlur = fontSize / 4;
|
||||
|
||||
//ctx.fillStyle = '#00ff00';
|
||||
//ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
ctx.fillText(text, margin, ctx.canvas.height / 2);
|
||||
|
||||
const textMetrics = ctx.measureText(text);
|
||||
const cropWidth = (Math.ceil(textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft) + margin + margin);
|
||||
const cropHeight = (Math.ceil(textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) + margin + margin);
|
||||
const data = ctx.getImageData(0, (ctx.canvas.height / 2) - (cropHeight / 2), ctx.canvas.width, ctx.canvas.height);
|
||||
|
||||
const texture = createTexture(gl);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, cropWidth, cropHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
|
||||
const info = {
|
||||
texture: texture,
|
||||
width: cropWidth,
|
||||
height: cropHeight,
|
||||
};
|
||||
|
||||
ctx.canvas.remove();
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
async function createTextureFromQr(gl: WebGL2RenderingContext, options: { data: string | null }, resolution = 512): Promise<{ texture: WebGLTexture, width: number, height: number } | null> {
|
||||
const $i = ensureSignin();
|
||||
|
||||
const qrCodeInstance = new QRCodeStyling({
|
||||
width: resolution,
|
||||
height: resolution,
|
||||
margin: 42,
|
||||
type: 'canvas',
|
||||
data: options.data == null || options.data === '' ? `${url}/users/${$i.id}` : options.data,
|
||||
image: $i.avatarUrl,
|
||||
qrOptions: {
|
||||
typeNumber: 0,
|
||||
mode: 'Byte',
|
||||
errorCorrectionLevel: 'H',
|
||||
},
|
||||
imageOptions: {
|
||||
hideBackgroundDots: true,
|
||||
imageSize: 0.3,
|
||||
margin: 16,
|
||||
crossOrigin: 'anonymous',
|
||||
},
|
||||
dotsOptions: {
|
||||
type: 'dots',
|
||||
},
|
||||
cornersDotOptions: {
|
||||
type: 'dot',
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: 'extra-rounded',
|
||||
},
|
||||
});
|
||||
|
||||
const blob = await qrCodeInstance.getRawData('png') as Blob | null;
|
||||
if (blob == null) return null;
|
||||
|
||||
const image = await window.createImageBitmap(blob);
|
||||
|
||||
const texture = createTexture(gl);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, resolution, resolution, 0, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
|
||||
return {
|
||||
texture,
|
||||
width: resolution,
|
||||
height: resolution,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,43 +3,47 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { FX_checker } from './fxs/checker.js';
|
||||
import { FX_chromaticAberration } from './fxs/chromaticAberration.js';
|
||||
import { FX_colorAdjust } from './fxs/colorAdjust.js';
|
||||
import { FX_colorClamp } from './fxs/colorClamp.js';
|
||||
import { FX_colorClampAdvanced } from './fxs/colorClampAdvanced.js';
|
||||
import { FX_distort } from './fxs/distort.js';
|
||||
import { FX_polkadot } from './fxs/polkadot.js';
|
||||
import { FX_tearing } from './fxs/tearing.js';
|
||||
import { FX_grayscale } from './fxs/grayscale.js';
|
||||
import { FX_invert } from './fxs/invert.js';
|
||||
import { FX_mirror } from './fxs/mirror.js';
|
||||
import { FX_stripe } from './fxs/stripe.js';
|
||||
import { FX_threshold } from './fxs/threshold.js';
|
||||
import { FX_zoomLines } from './fxs/zoomLines.js';
|
||||
import { FX_blockNoise } from './fxs/blockNoise.js';
|
||||
import { FX_fill } from './fxs/fill.js';
|
||||
import { FX_blur } from './fxs/blur.js';
|
||||
import { FX_pixelate } from './fxs/pixelate.js';
|
||||
import type { ImageEffectorFx } from './ImageEffector.js';
|
||||
import * as checker from '../image-compositor-functions/checker.js';
|
||||
import * as chromaticAberration from '../image-compositor-functions/chromaticAberration.js';
|
||||
import * as colorAdjust from '../image-compositor-functions/colorAdjust.js';
|
||||
import * as colorClamp from '../image-compositor-functions/colorClamp.js';
|
||||
import * as colorClampAdvanced from '../image-compositor-functions/colorClampAdvanced.js';
|
||||
import * as distort from '../image-compositor-functions/distort.js';
|
||||
import * as polkadot from '../image-compositor-functions/polkadot.js';
|
||||
import * as tearing from '../image-compositor-functions/tearing.js';
|
||||
import * as grayscale from '../image-compositor-functions/grayscale.js';
|
||||
import * as invert from '../image-compositor-functions/invert.js';
|
||||
import * as mirror from '../image-compositor-functions/mirror.js';
|
||||
import * as stripe from '../image-compositor-functions/stripe.js';
|
||||
import * as threshold from '../image-compositor-functions/threshold.js';
|
||||
import * as zoomLines from '../image-compositor-functions/zoomLines.js';
|
||||
import * as blockNoise from '../image-compositor-functions/blockNoise.js';
|
||||
import * as fill from '../image-compositor-functions/fill.js';
|
||||
import * as blur from '../image-compositor-functions/blur.js';
|
||||
import * as pixelate from '../image-compositor-functions/pixelate.js';
|
||||
import type { ImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import type { ImageEffectorUiDefinition } from './ImageEffector.js';
|
||||
|
||||
export const FXS = [
|
||||
FX_mirror,
|
||||
FX_invert,
|
||||
FX_grayscale,
|
||||
FX_colorAdjust,
|
||||
FX_colorClamp,
|
||||
FX_colorClampAdvanced,
|
||||
FX_distort,
|
||||
FX_threshold,
|
||||
FX_zoomLines,
|
||||
FX_stripe,
|
||||
FX_polkadot,
|
||||
FX_checker,
|
||||
FX_chromaticAberration,
|
||||
FX_tearing,
|
||||
FX_blockNoise,
|
||||
FX_fill,
|
||||
FX_blur,
|
||||
FX_pixelate,
|
||||
] as const satisfies ImageEffectorFx<string, any>[];
|
||||
export const FXS = {
|
||||
checker,
|
||||
chromaticAberration,
|
||||
colorAdjust,
|
||||
colorClamp,
|
||||
colorClampAdvanced,
|
||||
distort,
|
||||
polkadot,
|
||||
tearing,
|
||||
grayscale,
|
||||
invert,
|
||||
mirror,
|
||||
stripe,
|
||||
threshold,
|
||||
zoomLines,
|
||||
blockNoise,
|
||||
fill,
|
||||
blur,
|
||||
pixelate,
|
||||
} as const satisfies Record<string, {
|
||||
readonly fn: ImageCompositorFunction<any>;
|
||||
readonly uiDefinition: ImageEffectorUiDefinition<any>;
|
||||
}>;
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import shader from './grayscale.glsl';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export const FX_grayscale = defineImageEffectorFx({
|
||||
id: 'grayscale',
|
||||
name: i18n.ts._imageEffector._fxs.grayscale,
|
||||
shader,
|
||||
uniforms: [] as const,
|
||||
params: {
|
||||
},
|
||||
main: ({ gl, params }) => {
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import QRCodeStyling from 'qr-code-styling';
|
||||
import { url } from '@@/js/config.js';
|
||||
import ExifReader from 'exifreader';
|
||||
import { FN_frame } from './frame.js';
|
||||
import { ImageCompositor } from '@/lib/ImageCompositor.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
type LabelParams = {
|
||||
enabled: boolean;
|
||||
scale: number;
|
||||
padding: number;
|
||||
textBig: string;
|
||||
textSmall: string;
|
||||
centered: boolean;
|
||||
withQrCode: boolean;
|
||||
};
|
||||
|
||||
export type ImageFrameParams = {
|
||||
borderThickness: number;
|
||||
labelTop: LabelParams;
|
||||
labelBottom: LabelParams;
|
||||
bgColor: [r: number, g: number, b: number];
|
||||
fgColor: [r: number, g: number, b: number];
|
||||
font: 'serif' | 'sans-serif';
|
||||
borderRadius: number; // TODO
|
||||
};
|
||||
|
||||
export type ImageFramePreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
params: ImageFrameParams;
|
||||
};
|
||||
|
||||
export class ImageFrameRenderer {
|
||||
private compositor: ImageCompositor<{ frame: typeof FN_frame }>;
|
||||
private image: HTMLImageElement | ImageBitmap;
|
||||
private exif: ExifReader.Tags | null;
|
||||
private caption: string | null = null;
|
||||
private filename: string | null = null;
|
||||
private renderAsPreview = false;
|
||||
|
||||
constructor(options: {
|
||||
canvas: HTMLCanvasElement,
|
||||
image: HTMLImageElement | ImageBitmap,
|
||||
exif: ExifReader.Tags | null,
|
||||
filename: string | null,
|
||||
caption: string | null,
|
||||
renderAsPreview?: boolean,
|
||||
}) {
|
||||
this.image = options.image;
|
||||
this.exif = options.exif;
|
||||
this.caption = options.caption ?? null;
|
||||
this.filename = options.filename ?? null;
|
||||
this.renderAsPreview = options.renderAsPreview ?? false;
|
||||
|
||||
this.compositor = new ImageCompositor({
|
||||
canvas: options.canvas,
|
||||
renderWidth: 1,
|
||||
renderHeight: 1,
|
||||
image: null,
|
||||
functions: { frame: FN_frame },
|
||||
});
|
||||
|
||||
this.compositor.registerTexture('image', this.image);
|
||||
}
|
||||
|
||||
private interpolateTemplateText(text: string) {
|
||||
const DateTimeOriginal = this.exif == null ? '2012:03:04 5:06:07' : this.exif.DateTimeOriginal?.description;
|
||||
const Model = this.exif == null ? 'Example camera' : this.exif.Model?.description;
|
||||
const LensModel = this.exif == null ? 'Example lens 123mm f/1.23' : this.exif.LensModel?.description;
|
||||
const FocalLength = this.exif == null ? '123mm' : this.exif.FocalLength?.description;
|
||||
const FocalLengthIn35mmFilm = this.exif == null ? '123mm' : this.exif.FocalLengthIn35mmFilm?.description;
|
||||
const ExposureTime = this.exif == null ? '1/234' : this.exif.ExposureTime?.description;
|
||||
const FNumber = this.exif == null ? '1.23' : this.exif.FNumber?.description;
|
||||
const ISOSpeedRatings = this.exif == null ? '123' : this.exif.ISOSpeedRatings?.description;
|
||||
const GPSLatitude = this.exif == null ? '123.000000000000123' : this.exif.GPSLatitude?.description;
|
||||
const GPSLongitude = this.exif == null ? '456.000000000000123' : this.exif.GPSLongitude?.description;
|
||||
return text.replaceAll(/\{(\w+)\}/g, (_: string, key: string) => {
|
||||
const meta_date = DateTimeOriginal ?? '????:??:?? ??:??:??';
|
||||
const date = meta_date.split(' ')[0].replaceAll(':', '/');
|
||||
switch (key) {
|
||||
case 'caption': return this.caption ?? '?';
|
||||
case 'filename': return this.filename ?? '?';
|
||||
case 'filename_without_ext': return this.filename?.replace(/\.[^/.]+$/, '') ?? '?';
|
||||
case 'year': return date.split('/')[0];
|
||||
case 'month': return date.split('/')[1].replace(/^0/, '');
|
||||
case 'day': return date.split('/')[2].replace(/^0/, '');
|
||||
case 'hour': return meta_date.split(' ')[1].split(':')[0].replace(/^0/, '');
|
||||
case 'minute': return meta_date.split(' ')[1].split(':')[1].replace(/^0/, '');
|
||||
case 'second': return meta_date.split(' ')[1].split(':')[2].replace(/^0/, '');
|
||||
case '0month': return date.split('/')[1];
|
||||
case '0day': return date.split('/')[2];
|
||||
case '0hour': return meta_date.split(' ')[1].split(':')[0];
|
||||
case '0minute': return meta_date.split(' ')[1].split(':')[1];
|
||||
case '0second': return meta_date.split(' ')[1].split(':')[2];
|
||||
case 'camera_model': return Model ?? '?';
|
||||
case 'camera_lens_model': return LensModel ?? '?';
|
||||
case 'camera_mm': return FocalLength?.replace(' mm', '').replace('mm', '') ?? '?';
|
||||
case 'camera_mm_35': return FocalLengthIn35mmFilm?.replace(' mm', '').replace('mm', '') ?? '?';
|
||||
case 'camera_f': return FNumber?.replace('f/', '') ?? '?';
|
||||
case 'camera_s': return ExposureTime ?? '?';
|
||||
case 'camera_iso': return ISOSpeedRatings ?? '?';
|
||||
case 'gps_lat': return GPSLatitude ?? '?';
|
||||
case 'gps_long': return GPSLongitude ?? '?';
|
||||
default: return '?';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async renderLabel(renderWidth: number, renderHeight: number, paddingLeft: number, paddingRight: number, imageAreaH: number, fgColor: [number, number, number], font: string, params: LabelParams) {
|
||||
const scaleBase = imageAreaH * params.scale;
|
||||
const labelCanvasCtx = window.document.createElement('canvas').getContext('2d')!;
|
||||
labelCanvasCtx.canvas.width = renderWidth;
|
||||
labelCanvasCtx.canvas.height = renderHeight;
|
||||
const fontSize = scaleBase / 30;
|
||||
const textsMarginLeft = Math.max(fontSize * 2, paddingLeft);
|
||||
const textsMarginRight = textsMarginLeft;
|
||||
const withQrCode = params.withQrCode;
|
||||
const qrSize = scaleBase * 0.1;
|
||||
const qrMarginRight = Math.max((labelCanvasCtx.canvas.height - qrSize) / 2, paddingRight);
|
||||
|
||||
labelCanvasCtx.fillStyle = `rgb(${Math.floor(fgColor[0] * 255)}, ${Math.floor(fgColor[1] * 255)}, ${Math.floor(fgColor[2] * 255)})`;
|
||||
labelCanvasCtx.font = `bold ${fontSize}px ${font}`;
|
||||
labelCanvasCtx.textBaseline = 'middle';
|
||||
|
||||
const titleY = params.textSmall === '' ? (labelCanvasCtx.canvas.height / 2) : (labelCanvasCtx.canvas.height / 2) - (fontSize * 0.9);
|
||||
if (params.centered) {
|
||||
labelCanvasCtx.textAlign = 'center';
|
||||
labelCanvasCtx.fillText(this.interpolateTemplateText(params.textBig), labelCanvasCtx.canvas.width / 2, titleY, labelCanvasCtx.canvas.width - textsMarginLeft - textsMarginRight);
|
||||
} else {
|
||||
labelCanvasCtx.textAlign = 'left';
|
||||
labelCanvasCtx.fillText(this.interpolateTemplateText(params.textBig), textsMarginLeft, titleY, labelCanvasCtx.canvas.width - textsMarginLeft - (withQrCode ? (qrSize + qrMarginRight + (fontSize * 1)) : textsMarginRight));
|
||||
}
|
||||
|
||||
labelCanvasCtx.fillStyle = `rgba(${Math.floor(fgColor[0] * 255)}, ${Math.floor(fgColor[1] * 255)}, ${Math.floor(fgColor[2] * 255)}, 0.5)`;
|
||||
labelCanvasCtx.font = `${fontSize * 0.85}px ${font}`;
|
||||
labelCanvasCtx.textBaseline = 'middle';
|
||||
|
||||
const textY = params.textBig === '' ? (labelCanvasCtx.canvas.height / 2) : (labelCanvasCtx.canvas.height / 2) + (fontSize * 0.9);
|
||||
if (params.centered) {
|
||||
labelCanvasCtx.textAlign = 'center';
|
||||
labelCanvasCtx.fillText(this.interpolateTemplateText(params.textSmall), labelCanvasCtx.canvas.width / 2, textY, labelCanvasCtx.canvas.width - textsMarginLeft - textsMarginRight);
|
||||
} else {
|
||||
labelCanvasCtx.textAlign = 'left';
|
||||
labelCanvasCtx.fillText(this.interpolateTemplateText(params.textSmall), textsMarginLeft, textY, labelCanvasCtx.canvas.width - textsMarginLeft - (withQrCode ? (qrSize + qrMarginRight + (fontSize * 1)) : textsMarginRight));
|
||||
}
|
||||
|
||||
if (withQrCode) {
|
||||
try {
|
||||
const qrCodeInstance = new QRCodeStyling({
|
||||
width: labelCanvasCtx.canvas.height,
|
||||
height: labelCanvasCtx.canvas.height,
|
||||
margin: 0,
|
||||
type: 'canvas',
|
||||
data: `${url}/users/${$i.id}`,
|
||||
//image: $i.avatarUrl,
|
||||
qrOptions: {
|
||||
typeNumber: 0,
|
||||
mode: 'Byte',
|
||||
errorCorrectionLevel: 'H',
|
||||
},
|
||||
imageOptions: {
|
||||
hideBackgroundDots: true,
|
||||
imageSize: 0.3,
|
||||
margin: 16,
|
||||
crossOrigin: 'anonymous',
|
||||
},
|
||||
dotsOptions: {
|
||||
type: 'dots',
|
||||
roundSize: false,
|
||||
color: `rgb(${Math.floor(fgColor[0] * 255)}, ${Math.floor(fgColor[1] * 255)}, ${Math.floor(fgColor[2] * 255)})`,
|
||||
},
|
||||
backgroundOptions: {
|
||||
color: 'transparent',
|
||||
},
|
||||
cornersDotOptions: {
|
||||
type: 'dot',
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: 'extra-rounded',
|
||||
},
|
||||
});
|
||||
|
||||
const blob = await qrCodeInstance.getRawData('png') as Blob | null;
|
||||
if (blob == null) throw new Error('Failed to generate QR code');
|
||||
|
||||
const qrImageBitmap = await window.createImageBitmap(blob);
|
||||
|
||||
labelCanvasCtx.drawImage(
|
||||
qrImageBitmap,
|
||||
labelCanvasCtx.canvas.width - qrSize - qrMarginRight,
|
||||
(labelCanvasCtx.canvas.height - qrSize) / 2,
|
||||
qrSize,
|
||||
qrSize,
|
||||
);
|
||||
qrImageBitmap.close();
|
||||
} catch (err) {
|
||||
// nop
|
||||
}
|
||||
}
|
||||
|
||||
return labelCanvasCtx.getImageData(0, 0, labelCanvasCtx.canvas.width, labelCanvasCtx.canvas.height); ;
|
||||
}
|
||||
|
||||
public async render(params: ImageFrameParams): Promise<void> {
|
||||
let imageAreaW = this.image.width;
|
||||
let imageAreaH = this.image.height;
|
||||
|
||||
if (this.renderAsPreview) {
|
||||
const MAX_W = 1000;
|
||||
const MAX_H = 1000;
|
||||
|
||||
if (imageAreaW > MAX_W || imageAreaH > MAX_H) {
|
||||
const scale = Math.min(MAX_W / imageAreaW, MAX_H / imageAreaH);
|
||||
imageAreaW = Math.floor(imageAreaW * scale);
|
||||
imageAreaH = Math.floor(imageAreaH * scale);
|
||||
}
|
||||
}
|
||||
|
||||
const paddingLeft = Math.floor(imageAreaH * params.borderThickness);
|
||||
const paddingRight = Math.floor(imageAreaH * params.borderThickness);
|
||||
const paddingTop = params.labelTop.enabled ? Math.floor(imageAreaH * params.labelTop.padding) : Math.floor(imageAreaH * params.borderThickness);
|
||||
const paddingBottom = params.labelBottom.enabled ? Math.floor(imageAreaH * params.labelBottom.padding) : Math.floor(imageAreaH * params.borderThickness);
|
||||
const renderWidth = imageAreaW + paddingLeft + paddingRight;
|
||||
const renderHeight = imageAreaH + paddingTop + paddingBottom;
|
||||
|
||||
if (params.labelTop.enabled) {
|
||||
const topLabelImage = await this.renderLabel(renderWidth, paddingTop, paddingLeft, paddingRight, imageAreaH, params.fgColor, params.font, params.labelTop);
|
||||
this.compositor.registerTexture('topLabel', topLabelImage);
|
||||
}
|
||||
|
||||
if (params.labelBottom.enabled) {
|
||||
const bottomLabelImage = await this.renderLabel(renderWidth, paddingBottom, paddingLeft, paddingRight, imageAreaH, params.fgColor, params.font, params.labelBottom);
|
||||
this.compositor.registerTexture('bottomLabel', bottomLabelImage);
|
||||
}
|
||||
|
||||
this.compositor.changeResolution(renderWidth, renderHeight);
|
||||
|
||||
this.compositor.render([{
|
||||
functionId: 'frame',
|
||||
id: 'a',
|
||||
params: {
|
||||
image: 'image',
|
||||
topLabel: 'topLabel',
|
||||
bottomLabel: 'bottomLabel',
|
||||
topLabelEnabled: params.labelTop.enabled,
|
||||
bottomLabelEnabled: params.labelBottom.enabled,
|
||||
paddingLeft: paddingLeft / renderWidth,
|
||||
paddingRight: paddingRight / renderWidth,
|
||||
paddingTop: paddingTop / renderHeight,
|
||||
paddingBottom: paddingBottom / renderHeight,
|
||||
bg: params.bgColor,
|
||||
},
|
||||
}]);
|
||||
}
|
||||
|
||||
/*
|
||||
* disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意
|
||||
*/
|
||||
public destroy(disposeCanvas = true): void {
|
||||
this.compositor.destroy(disposeCanvas);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
#version 300 es
|
||||
precision mediump float;
|
||||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
in vec2 in_uv;
|
||||
uniform sampler2D in_texture;
|
||||
uniform vec2 in_resolution;
|
||||
uniform sampler2D u_image;
|
||||
uniform sampler2D u_topLabel;
|
||||
uniform sampler2D u_bottomLabel;
|
||||
uniform bool u_topLabelEnabled;
|
||||
uniform bool u_bottomLabelEnabled;
|
||||
uniform float u_paddingTop;
|
||||
uniform float u_paddingBottom;
|
||||
uniform float u_paddingLeft;
|
||||
uniform float u_paddingRight;
|
||||
uniform vec3 u_bg;
|
||||
out vec4 out_color;
|
||||
|
||||
float remap(float value, float inputMin, float inputMax, float outputMin, float outputMax) {
|
||||
return outputMin + (outputMax - outputMin) * ((value - inputMin) / (inputMax - inputMin));
|
||||
}
|
||||
|
||||
vec3 blendAlpha(vec3 bg, vec4 fg) {
|
||||
return fg.a * fg.rgb + (1.0 - fg.a) * bg;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 bg = vec4(u_bg, 1.0);
|
||||
|
||||
vec4 image_color = texture(u_image, vec2(
|
||||
remap(in_uv.x, u_paddingLeft, 1.0 - u_paddingRight, 0.0, 1.0),
|
||||
remap(in_uv.y, u_paddingTop, 1.0 - u_paddingBottom, 0.0, 1.0)
|
||||
));
|
||||
|
||||
vec4 topLabel_color = u_topLabelEnabled ? texture(u_topLabel, vec2(
|
||||
in_uv.x,
|
||||
remap(in_uv.y, 0.0, u_paddingTop, 0.0, 1.0)
|
||||
)) : bg;
|
||||
|
||||
vec4 bottomLabel_color = u_bottomLabelEnabled ? texture(u_bottomLabel, vec2(
|
||||
in_uv.x,
|
||||
remap(in_uv.y, 1.0 - u_paddingBottom, 1.0, 0.0, 1.0)
|
||||
)) : bg;
|
||||
|
||||
if (in_uv.y < u_paddingTop) {
|
||||
out_color = vec4(blendAlpha(bg.rgb, topLabel_color), 1.0);
|
||||
} else if (in_uv.y > (1.0 - u_paddingBottom)) {
|
||||
out_color = vec4(blendAlpha(bg.rgb, bottomLabel_color), 1.0);
|
||||
} else {
|
||||
if (in_uv.y > u_paddingTop && in_uv.x > u_paddingLeft && in_uv.x < (1.0 - u_paddingRight)) {
|
||||
out_color = image_color;
|
||||
} else {
|
||||
out_color = bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import shader from './frame.glsl';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
|
||||
export const FN_frame = defineImageCompositorFunction<{
|
||||
image: string | null;
|
||||
topLabel: string | null;
|
||||
bottomLabel: string | null;
|
||||
topLabelEnabled: boolean;
|
||||
bottomLabelEnabled: boolean;
|
||||
paddingTop: number;
|
||||
paddingBottom: number;
|
||||
paddingLeft: number;
|
||||
paddingRight: number;
|
||||
bg: [number, number, number];
|
||||
}>({
|
||||
shader,
|
||||
main: ({ gl, u, params, textures }) => {
|
||||
if (params.image == null) return;
|
||||
const image = textures.get(params.image);
|
||||
if (image == null) return;
|
||||
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, image.texture);
|
||||
gl.uniform1i(u.image, 1);
|
||||
|
||||
gl.uniform1i(u.topLabelEnabled, params.topLabelEnabled ? 1 : 0);
|
||||
gl.uniform1i(u.bottomLabelEnabled, params.bottomLabelEnabled ? 1 : 0);
|
||||
gl.uniform1f(u.paddingTop, params.paddingTop);
|
||||
gl.uniform1f(u.paddingBottom, params.paddingBottom);
|
||||
gl.uniform1f(u.paddingLeft, params.paddingLeft);
|
||||
gl.uniform1f(u.paddingRight, params.paddingRight);
|
||||
gl.uniform3f(u.bg, params.bg[0], params.bg[1], params.bg[2]);
|
||||
|
||||
if (params.topLabelEnabled && params.topLabel != null) {
|
||||
const topLabel = textures.get(params.topLabel);
|
||||
if (topLabel) {
|
||||
gl.activeTexture(gl.TEXTURE2);
|
||||
gl.bindTexture(gl.TEXTURE_2D, topLabel.texture);
|
||||
gl.uniform1i(u.topLabel, 2);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.bottomLabelEnabled && params.bottomLabel != null) {
|
||||
const bottomLabel = textures.get(params.bottomLabel);
|
||||
if (bottomLabel) {
|
||||
gl.activeTexture(gl.TEXTURE3);
|
||||
gl.bindTexture(gl.TEXTURE_2D, bottomLabel.texture);
|
||||
gl.uniform1i(u.bottomLabel, 3);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { ImageEffectorFx, ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
|
||||
import { FX_watermarkPlacement } from '@/utility/image-effector/fxs/watermarkPlacement.js';
|
||||
import { FX_stripe } from '@/utility/image-effector/fxs/stripe.js';
|
||||
import { FX_polkadot } from '@/utility/image-effector/fxs/polkadot.js';
|
||||
import { FX_checker } from '@/utility/image-effector/fxs/checker.js';
|
||||
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
|
||||
|
||||
const WATERMARK_FXS = [
|
||||
FX_watermarkPlacement,
|
||||
FX_stripe,
|
||||
FX_polkadot,
|
||||
FX_checker,
|
||||
] as const satisfies ImageEffectorFx<string, any>[];
|
||||
|
||||
type Align = { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; margin?: number; };
|
||||
|
||||
export type WatermarkPreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
layers: ({
|
||||
id: string;
|
||||
type: 'text';
|
||||
text: string;
|
||||
repeat: boolean;
|
||||
noBoundingBoxExpansion: boolean;
|
||||
scale: number;
|
||||
angle: number;
|
||||
align: Align;
|
||||
opacity: number;
|
||||
} | {
|
||||
id: string;
|
||||
type: 'image';
|
||||
imageUrl: string | null;
|
||||
imageId: string | null;
|
||||
cover: boolean;
|
||||
repeat: boolean;
|
||||
noBoundingBoxExpansion: boolean;
|
||||
scale: number;
|
||||
angle: number;
|
||||
align: Align;
|
||||
opacity: number;
|
||||
} | {
|
||||
id: string;
|
||||
type: 'qr';
|
||||
data: string;
|
||||
scale: number;
|
||||
align: Align;
|
||||
opacity: number;
|
||||
} | {
|
||||
id: string;
|
||||
type: 'stripe';
|
||||
angle: number;
|
||||
frequency: number;
|
||||
threshold: number;
|
||||
color: [r: number, g: number, b: number];
|
||||
opacity: number;
|
||||
} | {
|
||||
id: string;
|
||||
type: 'polkadot';
|
||||
angle: number;
|
||||
scale: number;
|
||||
majorRadius: number;
|
||||
majorOpacity: number;
|
||||
minorDivisions: number;
|
||||
minorRadius: number;
|
||||
minorOpacity: number;
|
||||
color: [r: number, g: number, b: number];
|
||||
opacity: number;
|
||||
} | {
|
||||
id: string;
|
||||
type: 'checker';
|
||||
angle: number;
|
||||
scale: number;
|
||||
color: [r: number, g: number, b: number];
|
||||
opacity: number;
|
||||
})[];
|
||||
};
|
||||
|
||||
export class WatermarkRenderer {
|
||||
private effector: ImageEffector<typeof WATERMARK_FXS>;
|
||||
private layers: WatermarkPreset['layers'] = [];
|
||||
|
||||
constructor(options: {
|
||||
canvas: HTMLCanvasElement,
|
||||
renderWidth: number,
|
||||
renderHeight: number,
|
||||
image: HTMLImageElement | ImageBitmap,
|
||||
}) {
|
||||
this.effector = new ImageEffector({
|
||||
canvas: options.canvas,
|
||||
renderWidth: options.renderWidth,
|
||||
renderHeight: options.renderHeight,
|
||||
image: options.image,
|
||||
fxs: WATERMARK_FXS,
|
||||
});
|
||||
}
|
||||
|
||||
private makeImageEffectorLayers(): ImageEffectorLayer[] {
|
||||
return this.layers.map(layer => {
|
||||
if (layer.type === 'text') {
|
||||
return {
|
||||
fxId: 'watermarkPlacement',
|
||||
id: layer.id,
|
||||
params: {
|
||||
repeat: layer.repeat,
|
||||
noBoundingBoxExpansion: layer.noBoundingBoxExpansion,
|
||||
scale: layer.scale,
|
||||
align: layer.align,
|
||||
angle: layer.angle,
|
||||
opacity: layer.opacity,
|
||||
cover: false,
|
||||
watermark: {
|
||||
type: 'text',
|
||||
text: layer.text,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (layer.type === 'image') {
|
||||
return {
|
||||
fxId: 'watermarkPlacement',
|
||||
id: layer.id,
|
||||
params: {
|
||||
repeat: layer.repeat,
|
||||
noBoundingBoxExpansion: layer.noBoundingBoxExpansion,
|
||||
scale: layer.scale,
|
||||
align: layer.align,
|
||||
angle: layer.angle,
|
||||
opacity: layer.opacity,
|
||||
cover: layer.cover,
|
||||
watermark: {
|
||||
type: 'url',
|
||||
url: layer.imageUrl,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (layer.type === 'qr') {
|
||||
return {
|
||||
fxId: 'watermarkPlacement',
|
||||
id: layer.id,
|
||||
params: {
|
||||
repeat: false,
|
||||
scale: layer.scale,
|
||||
align: layer.align,
|
||||
angle: 0,
|
||||
opacity: layer.opacity,
|
||||
cover: false,
|
||||
watermark: {
|
||||
type: 'qr',
|
||||
data: layer.data,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (layer.type === 'stripe') {
|
||||
return {
|
||||
fxId: 'stripe',
|
||||
id: layer.id,
|
||||
params: {
|
||||
angle: layer.angle,
|
||||
frequency: layer.frequency,
|
||||
threshold: layer.threshold,
|
||||
color: layer.color,
|
||||
opacity: layer.opacity,
|
||||
},
|
||||
};
|
||||
} else if (layer.type === 'polkadot') {
|
||||
return {
|
||||
fxId: 'polkadot',
|
||||
id: layer.id,
|
||||
params: {
|
||||
angle: layer.angle,
|
||||
scale: layer.scale,
|
||||
majorRadius: layer.majorRadius,
|
||||
majorOpacity: layer.majorOpacity,
|
||||
minorDivisions: layer.minorDivisions,
|
||||
minorRadius: layer.minorRadius,
|
||||
minorOpacity: layer.minorOpacity,
|
||||
color: layer.color,
|
||||
},
|
||||
};
|
||||
} else if (layer.type === 'checker') {
|
||||
return {
|
||||
fxId: 'checker',
|
||||
id: layer.id,
|
||||
params: {
|
||||
angle: layer.angle,
|
||||
scale: layer.scale,
|
||||
color: layer.color,
|
||||
opacity: layer.opacity,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Unrecognized layer type: ${(layer as any).type}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async setLayers(layers: WatermarkPreset['layers']) {
|
||||
this.layers = layers;
|
||||
await this.effector.setLayers(this.makeImageEffectorLayers());
|
||||
this.render();
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
this.effector.render();
|
||||
}
|
||||
|
||||
/*
|
||||
* disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意
|
||||
*/
|
||||
public destroy(disposeCanvas = true): void {
|
||||
this.effector.destroy(disposeCanvas);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,332 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import QRCodeStyling from 'qr-code-styling';
|
||||
import { url, host } from '@@/js/config.js';
|
||||
import { getProxiedImageUrl } from '../media-proxy.js';
|
||||
import { fn as fn_watermark } from './watermark.js';
|
||||
import { fn as fn_stripe } from '@/utility/image-compositor-functions/stripe.js';
|
||||
import { fn as fn_poladot } from '@/utility/image-compositor-functions/polkadot.js';
|
||||
import { fn as fn_checker } from '@/utility/image-compositor-functions/checker.js';
|
||||
import { ImageCompositor } from '@/lib/ImageCompositor.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
|
||||
type Align = { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; margin?: number; };
|
||||
|
||||
export type WatermarkLayers = ({
|
||||
id: string;
|
||||
type: 'text';
|
||||
text: string;
|
||||
repeat: boolean;
|
||||
noBoundingBoxExpansion: boolean;
|
||||
scale: number;
|
||||
angle: number;
|
||||
align: Align;
|
||||
opacity: number;
|
||||
} | {
|
||||
id: string;
|
||||
type: 'image';
|
||||
imageUrl: string | null;
|
||||
imageId: string | null;
|
||||
cover: boolean;
|
||||
repeat: boolean;
|
||||
noBoundingBoxExpansion: boolean;
|
||||
scale: number;
|
||||
angle: number;
|
||||
align: Align;
|
||||
opacity: number;
|
||||
} | {
|
||||
id: string;
|
||||
type: 'qr';
|
||||
data: string;
|
||||
scale: number;
|
||||
align: Align;
|
||||
opacity: number;
|
||||
} | {
|
||||
id: string;
|
||||
type: 'stripe';
|
||||
angle: number;
|
||||
frequency: number;
|
||||
threshold: number;
|
||||
color: [r: number, g: number, b: number];
|
||||
opacity: number;
|
||||
} | {
|
||||
id: string;
|
||||
type: 'polkadot';
|
||||
angle: number;
|
||||
scale: number;
|
||||
majorRadius: number;
|
||||
majorOpacity: number;
|
||||
minorDivisions: number;
|
||||
minorRadius: number;
|
||||
minorOpacity: number;
|
||||
color: [r: number, g: number, b: number];
|
||||
opacity: number;
|
||||
} | {
|
||||
id: string;
|
||||
type: 'checker';
|
||||
angle: number;
|
||||
scale: number;
|
||||
color: [r: number, g: number, b: number];
|
||||
opacity: number;
|
||||
})[];
|
||||
|
||||
export type WatermarkPreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
layers: WatermarkLayers;
|
||||
};
|
||||
|
||||
type WatermarkRendererImageCompositor = ImageCompositor<{
|
||||
watermark: typeof fn_watermark;
|
||||
stripe: typeof fn_stripe;
|
||||
polkadot: typeof fn_poladot;
|
||||
checker: typeof fn_checker;
|
||||
}>;
|
||||
|
||||
export class WatermarkRenderer {
|
||||
private compositor: WatermarkRendererImageCompositor;
|
||||
|
||||
constructor(options: {
|
||||
canvas: HTMLCanvasElement,
|
||||
renderWidth: number,
|
||||
renderHeight: number,
|
||||
image: HTMLImageElement | ImageBitmap,
|
||||
}) {
|
||||
this.compositor = new ImageCompositor({
|
||||
canvas: options.canvas,
|
||||
renderWidth: options.renderWidth,
|
||||
renderHeight: options.renderHeight,
|
||||
image: options.image,
|
||||
functions: {
|
||||
watermark: fn_watermark,
|
||||
stripe: fn_stripe,
|
||||
polkadot: fn_poladot,
|
||||
checker: fn_checker,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async render(layers: WatermarkLayers) {
|
||||
const compositorLayers: Parameters<WatermarkRendererImageCompositor['render']>[0] = [];
|
||||
|
||||
const unused = new Set(this.compositor.getKeysOfRegisteredTextures());
|
||||
|
||||
for (const layer of layers) {
|
||||
if (layer.type === 'text') {
|
||||
const textureKey = `text:${layer.text}`;
|
||||
unused.delete(textureKey);
|
||||
if (!this.compositor.hasTexture(textureKey)) {
|
||||
if (_DEV_) console.log(`Baking text texture of <${textureKey}>...`);
|
||||
const image = await createTextureFromText(layer.text);
|
||||
if (image != null) this.compositor.registerTexture(textureKey, image);
|
||||
}
|
||||
|
||||
compositorLayers.push({
|
||||
functionId: 'watermark',
|
||||
id: layer.id,
|
||||
params: {
|
||||
repeat: layer.repeat,
|
||||
noBoundingBoxExpansion: layer.noBoundingBoxExpansion,
|
||||
scale: layer.scale,
|
||||
align: layer.align,
|
||||
angle: layer.angle,
|
||||
opacity: layer.opacity,
|
||||
cover: false,
|
||||
watermark: textureKey,
|
||||
},
|
||||
});
|
||||
} else if (layer.type === 'image') {
|
||||
const textureKey = `url:${layer.imageUrl}`;
|
||||
unused.delete(textureKey);
|
||||
if (!this.compositor.hasTexture(textureKey)) {
|
||||
if (_DEV_) console.log(`Baking url image texture of <${textureKey}>...`);
|
||||
const image = await createTextureFromUrl(layer.imageUrl);
|
||||
if (image != null) this.compositor.registerTexture(textureKey, image);
|
||||
}
|
||||
|
||||
compositorLayers.push({
|
||||
functionId: 'watermark',
|
||||
id: layer.id,
|
||||
params: {
|
||||
repeat: layer.repeat,
|
||||
noBoundingBoxExpansion: layer.noBoundingBoxExpansion,
|
||||
scale: layer.scale,
|
||||
align: layer.align,
|
||||
angle: layer.angle,
|
||||
opacity: layer.opacity,
|
||||
cover: layer.cover,
|
||||
watermark: textureKey,
|
||||
},
|
||||
});
|
||||
} else if (layer.type === 'qr') {
|
||||
const textureKey = `qr:${layer.data}`;
|
||||
unused.delete(textureKey);
|
||||
if (!this.compositor.hasTexture(textureKey)) {
|
||||
if (_DEV_) console.log(`Baking qr texture of <${textureKey}>...`);
|
||||
const image = await createTextureFromQr({ data: layer.data });
|
||||
if (image != null) this.compositor.registerTexture(textureKey, image);
|
||||
}
|
||||
|
||||
compositorLayers.push({
|
||||
functionId: 'watermark',
|
||||
id: layer.id,
|
||||
params: {
|
||||
repeat: false,
|
||||
scale: layer.scale,
|
||||
align: layer.align,
|
||||
angle: 0,
|
||||
opacity: layer.opacity,
|
||||
cover: false,
|
||||
watermark: textureKey,
|
||||
},
|
||||
});
|
||||
} else if (layer.type === 'stripe') {
|
||||
compositorLayers.push({
|
||||
functionId: 'stripe',
|
||||
id: layer.id,
|
||||
params: {
|
||||
angle: layer.angle,
|
||||
frequency: layer.frequency,
|
||||
threshold: layer.threshold,
|
||||
color: layer.color,
|
||||
opacity: layer.opacity,
|
||||
},
|
||||
});
|
||||
} else if (layer.type === 'polkadot') {
|
||||
compositorLayers.push({
|
||||
functionId: 'polkadot',
|
||||
id: layer.id,
|
||||
params: {
|
||||
angle: layer.angle,
|
||||
scale: layer.scale,
|
||||
majorRadius: layer.majorRadius,
|
||||
majorOpacity: layer.majorOpacity,
|
||||
minorDivisions: layer.minorDivisions,
|
||||
minorRadius: layer.minorRadius,
|
||||
minorOpacity: layer.minorOpacity,
|
||||
color: layer.color,
|
||||
},
|
||||
});
|
||||
} else if (layer.type === 'checker') {
|
||||
compositorLayers.push({
|
||||
functionId: 'checker',
|
||||
id: layer.id,
|
||||
params: {
|
||||
angle: layer.angle,
|
||||
scale: layer.scale,
|
||||
color: layer.color,
|
||||
opacity: layer.opacity,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unrecognized layer type: ${(layer as any).type}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const k of unused) {
|
||||
if (_DEV_) console.log(`Dispose unused texture <${k}>...`);
|
||||
this.compositor.unregisterTexture(k);
|
||||
}
|
||||
|
||||
this.compositor.render(compositorLayers);
|
||||
}
|
||||
|
||||
public changeResolution(width: number, height: number) {
|
||||
this.compositor.changeResolution(width, height);
|
||||
}
|
||||
|
||||
/*
|
||||
* disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意
|
||||
*/
|
||||
public destroy(disposeCanvas = true): void {
|
||||
this.compositor.destroy(disposeCanvas);
|
||||
}
|
||||
}
|
||||
|
||||
async function createTextureFromUrl(imageUrl: string | null) {
|
||||
if (imageUrl == null || imageUrl.trim() === '') return null;
|
||||
|
||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = getProxiedImageUrl(imageUrl); // CORS対策
|
||||
}).catch(() => null);
|
||||
|
||||
if (image == null) return null;
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
async function createTextureFromText(text: string | null, resolution = 2048) {
|
||||
if (text == null || text.trim() === '') return null;
|
||||
|
||||
const ctx = window.document.createElement('canvas').getContext('2d')!;
|
||||
ctx.canvas.width = resolution;
|
||||
ctx.canvas.height = resolution / 4;
|
||||
const fontSize = resolution / 32;
|
||||
const margin = fontSize / 2;
|
||||
ctx.shadowColor = '#000000';
|
||||
ctx.shadowBlur = fontSize / 4;
|
||||
|
||||
//ctx.fillStyle = '#00ff00';
|
||||
//ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
ctx.fillText(text, margin, ctx.canvas.height / 2);
|
||||
|
||||
const textMetrics = ctx.measureText(text);
|
||||
const cropWidth = (Math.ceil(textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft) + margin + margin);
|
||||
const cropHeight = (Math.ceil(textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) + margin + margin);
|
||||
const data = ctx.getImageData(0, (ctx.canvas.height / 2) - (cropHeight / 2), cropWidth, cropHeight);
|
||||
|
||||
ctx.canvas.remove();
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function createTextureFromQr(options: { data: string | null }, resolution = 512) {
|
||||
const $i = ensureSignin();
|
||||
|
||||
const qrCodeInstance = new QRCodeStyling({
|
||||
width: resolution,
|
||||
height: resolution,
|
||||
margin: 42,
|
||||
type: 'canvas',
|
||||
data: options.data == null || options.data === '' ? `${url}/users/${$i.id}` : options.data,
|
||||
image: $i.avatarUrl,
|
||||
qrOptions: {
|
||||
typeNumber: 0,
|
||||
mode: 'Byte',
|
||||
errorCorrectionLevel: 'H',
|
||||
},
|
||||
imageOptions: {
|
||||
hideBackgroundDots: true,
|
||||
imageSize: 0.3,
|
||||
margin: 16,
|
||||
crossOrigin: 'anonymous',
|
||||
},
|
||||
dotsOptions: {
|
||||
type: 'dots',
|
||||
},
|
||||
cornersDotOptions: {
|
||||
type: 'dot',
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: 'extra-rounded',
|
||||
},
|
||||
});
|
||||
|
||||
const blob = await qrCodeInstance.getRawData('png') as Blob | null;
|
||||
if (blob == null) return null;
|
||||
|
||||
const image = await window.createImageBitmap(blob);
|
||||
|
||||
return image;
|
||||
}
|
||||
|
|
@ -3,57 +3,20 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import shader from './watermarkPlacement.glsl';
|
||||
import shader from './watermark.glsl';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
|
||||
export const FX_watermarkPlacement = defineImageEffectorFx({
|
||||
id: 'watermarkPlacement',
|
||||
name: '(internal)',
|
||||
export const fn = defineImageCompositorFunction<Partial<{
|
||||
cover: boolean;
|
||||
repeat: boolean;
|
||||
scale: number;
|
||||
angle: number;
|
||||
align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; margin?: number; };
|
||||
opacity: number;
|
||||
noBoundingBoxExpansion: boolean;
|
||||
watermark: string | null;
|
||||
}>>({
|
||||
shader,
|
||||
uniforms: ['opacity', 'scale', 'angle', 'cover', 'repeat', 'alignX', 'alignY', 'margin', 'repeatMargin', 'noBBoxExpansion', 'wmResolution', 'wmEnabled', 'watermark'] as const,
|
||||
params: {
|
||||
cover: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
repeat: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
scale: {
|
||||
type: 'number',
|
||||
default: 0.3,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
},
|
||||
angle: {
|
||||
type: 'number',
|
||||
default: 0,
|
||||
min: -1.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
},
|
||||
align: {
|
||||
type: 'align',
|
||||
default: { x: 'right', y: 'bottom', margin: 0 },
|
||||
},
|
||||
opacity: {
|
||||
type: 'number',
|
||||
default: 0.75,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
},
|
||||
noBoundingBoxExpansion: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
watermark: {
|
||||
type: 'texture',
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params, textures }) => {
|
||||
// 基本パラメータ
|
||||
gl.uniform1f(u.opacity, params.opacity ?? 1.0);
|
||||
|
|
@ -70,7 +33,7 @@ export const FX_watermarkPlacement = defineImageEffectorFx({
|
|||
gl.uniform1i(u.noBBoxExpansion, params.noBoundingBoxExpansion ? 1 : 0);
|
||||
|
||||
// ウォーターマークテクスチャ
|
||||
const wm = textures.watermark;
|
||||
const wm = textures.get(params.watermark);
|
||||
if (wm) {
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, wm.texture);
|
||||
|
|
@ -38,3 +38,14 @@ export function initShaderProgram(gl: WebGL2RenderingContext, vsSource: string,
|
|||
|
||||
return shaderProgram;
|
||||
}
|
||||
|
||||
export function createTexture(gl: WebGL2RenderingContext): WebGLTexture {
|
||||
const texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
return texture;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -797,6 +797,9 @@ importers:
|
|||
execa:
|
||||
specifier: 9.6.0
|
||||
version: 9.6.0
|
||||
exifreader:
|
||||
specifier: 4.32.0
|
||||
version: 4.32.0
|
||||
frontend-shared:
|
||||
specifier: workspace:*
|
||||
version: link:../frontend-shared
|
||||
|
|
@ -4973,6 +4976,10 @@ packages:
|
|||
resolution: {integrity: sha512-siPY6BD5dQ2SZPl3I0OZBHL27ZqZvLEosObsZRQ1NUB8qcxegwt0T9eKtV96JMFQpIz1elhkzqOg4c/Ri6Dp9A==}
|
||||
engines: {node: ^14.14.0 || >=16.0.0}
|
||||
|
||||
'@xmldom/xmldom@0.9.8':
|
||||
resolution: {integrity: sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==}
|
||||
engines: {node: '>=14.6'}
|
||||
|
||||
abbrev@1.1.1:
|
||||
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
|
||||
|
||||
|
|
@ -6505,6 +6512,9 @@ packages:
|
|||
resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
exifreader@4.32.0:
|
||||
resolution: {integrity: sha512-sj1PzjpaPwSE/2MeUqoAYcfc2u7AZOGSby0FzmAkB4jjeCXgDryxzVgMwV+tJKGIkGdWkkWiUWoLSJoPHJ6V5Q==}
|
||||
|
||||
exit@0.1.2:
|
||||
resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
|
@ -7212,6 +7222,7 @@ packages:
|
|||
|
||||
intersection-observer@0.12.2:
|
||||
resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==}
|
||||
deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019.
|
||||
|
||||
ioredis@5.8.1:
|
||||
resolution: {integrity: sha512-Qho8TgIamqEPdgiMadJwzRMW3TudIg6vpg4YONokGDudy4eqRIJtDbVX72pfLBcWxvbn3qm/40TyGUObdW4tLQ==}
|
||||
|
|
@ -15621,6 +15632,9 @@ snapshots:
|
|||
dependencies:
|
||||
arch: 3.0.0
|
||||
|
||||
'@xmldom/xmldom@0.9.8':
|
||||
optional: true
|
||||
|
||||
abbrev@1.1.1: {}
|
||||
|
||||
abbrev@3.0.1: {}
|
||||
|
|
@ -17465,6 +17479,10 @@ snapshots:
|
|||
dependencies:
|
||||
pify: 2.3.0
|
||||
|
||||
exifreader@4.32.0:
|
||||
optionalDependencies:
|
||||
'@xmldom/xmldom': 0.9.8
|
||||
|
||||
exit@0.1.2: {}
|
||||
|
||||
expand-template@2.0.3:
|
||||
|
|
|
|||
Loading…
Reference in New Issue