たくさんの自由帳

Kotlin Multiplatform の関数をエクスポートして npm install してブラウザで使う

投稿日 : | 0 日前

文字数(だいたい) : 11944

どうもこんばんわ。
花鐘カナデ*グラム Chapter:3 星泉コトナ 攻略しました。前作やって無くてもいけそう!

!???!?!?!?!!

感想1

今作、物語がすごい進んだような気がする!!!最終章に向けての伏線的なのが。
最終章すごい気になる。

感想2

なにかありそう最後のヒロイン

感想3

↓このあとのシーンめっちゃ良かった。えちえちだった。

感想4

今作もだけど冒頭のやつ何なんだろう、、最後のヒロインで分かるのかな。
ちなみに、今作は最後かわいいイベント CG で終れてよかった!!
(Chapter 2 の一番最後は何だったんだ、、?)

かわいい!!!おすすめです!

感想5

感想6

本題

Kotlin Multiplatformを使うと、Kotlinの関数をエクスポートしてnpm ライブラリにして、npm installしてJavaScriptから呼び出すことが出来ます。

Use Kotlin code from JavaScript | Kotlin

https://kotlinlang.org/docs/js-to-kotlin-interop.html

今回ライブラリにするコード

今回はこれを使ってJavaScriptMediaRecorderをシークできるようにするコードを、npm installして、Reactとかで使えるようにしてみます。

これですが、現状はUIKotlin/JSのリソースの中にあるHTMLに書いている状況です。
これを、シーク処理Kotlinで書いてJS向けにエクスポート。それをReactか何かでUIを作り、呼び出すようにしてみます。

完成品

超実験的ですが、

以下のGitHubリポジトリを指定してnpm installしてください。
JavaScriptMediaRecorderが生成するWebMファイルをシークできるようにする関数を提供します。

npm install takusan23/himari-webm-kotlin-multiplatform-npm-library

今のところ以下の環境で動くことを確認しています。

  • Next.jsクライアントコンポーネントで使う
  • ReactViteで使う

参考

参考にしましあ!!

環境

Android StudioIDEAが必要?

npm ライブラリにしたあと、ブラウザで動かすには何かしらフロントエンドを作る必要があるのですが、今回はNext.jsで。
React + Viteでも動きます。

mapOf(
    "Android Studio" to "Android Studio Narwhal | 2025.1.1",
    "Kotlin" to "2.2.0",
    "Next.js" to "15.3.4"
)

作る

プロジェクトを作る

ここにKotlin Multiplatformのライブラリを作るテンプレートがあります。
別にKotlin Multiplatformのライブラリを作るわけじゃないです。がライブラリのテンプレートなので、ComposeMultiplatformとか入って無い構成。

このリポジトリをgit cloneして、Android Studioで開きます。

open

Gradle Syncが終わるまでまちます!
JSの世界から戻ってくるとやっぱり遅いねんこれ

sync

Wasm ターゲットを追加する

↑と同じです。

Kotlin/Wasmを追加します。
今回、npm installしてJS側から使う際にはWasmが使われるようにします。Kotlin/JSは分からず...

build.gradle.ktsを開いて、kotlin { }のところを探し、中に足します。

kotlin {
    // gradle wasmJsBrowserProductionRun
    // JavaScript からは、kotlin/wasm でライブラリとして npm で登録する。
    wasmJs {
        outputModuleName = "fix-seekable-webm-kotlin-wasm"
        binaries.executable()
        browser {
        }
        generateTypeScriptDefinitions()

        // https://kotlinlang.org/docs/wasm-js-interop.html#exception-handling
        compilerOptions {
            freeCompilerArgs.add("-Xwasm-attach-js-exception")
        }

        // npm の package.json に types を入れてくれないので
        // https://youtrack.jetbrains.com/issue/KT-77319
        // https://youtrack.jetbrains.com/issue/KT-77073
        compilations["main"].packageJson {
            this@packageJson.types = "kotlin/${outputModuleName.get()}.d.ts"
        }
    }

    // 以下省略
}

outputModuleNameが出力される.wasmファイルの名前だったり
binaries.executable()でブラウザで動くようになるはず。

generateTypeScriptDefinitions()で、公開したKotlinの関数に対応する、TypeScriptの型定義を作ってくれます。
ただ、その型定義をJS側で認識させるために、this@packageJson.typesを指定する必要があります。Issueがあるので今後修正されるはずです。

他にもjvm()androidTarget { }iosX64()がありますがまあどっちでも良いです。
使わないので消しても良いかもしれません。

Wasm 専用処理を書くフォルダを作る

プラットフォーム別の処理を書くフォルダです。今回はWasmですね。
スクショのようにディレクトリを作ると、wasmJsMainってディレクトリが生成されるはずです、

ディレクトリ

ここではwasmJsMainを選びます。

wasmJsMain

ドメインを使ったパッケージ名を作る

Kotlin Multiplatformでもこの考えが引き継がれているのかは、わからないのですが、今回は使います。
どういうわけかというと、自分の持っているドメインをひっくり返したものをパッケージとして使う文化があります。

https://takusan.negitoro.dev/だったら、パッケージ名がdev.negitoro.takusanになるわけです。
この後にアプリ名がつくので、dev.negitoro.takusan.{APP名}になるでしょうか。

ドメインなければGitHub Pages.ioドメインを使ったり。
要は被らなければよいわけです。

というわけでやります。私はずっとGitHub Pagesのを使っているので今回もこれで。あと後ろにアプリ名を``付与して、以下のようになりました。
io.github.takusan23.fixseekablewebmkotlinwasm

パッケージ名

まあね、Kotlin/WasmJSライブラリにするだけなら、多分する必要ないです。

コードを書く

書きます。
今回はすでにあるコードをコピーするだけ!

クラス

Kotlin Multiplatformなので、Kotlin標準ライブラリか、Multiplatform対応ライブラリを使う必要があります。
ご存知でしょうが、例えばJVMのクラスは、JVMターゲットでは使えますが、wasm (JavaScript)ではもちろん使えません。

JavaScript 側に公開する準備

wasmJsMainKotlinコードでは、JavaScriptに公開する(エクスポートする)ためのアノテーションが利用できます。
https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.js/-js-export/

なので、wasmJsMainJavaScript側から呼び出したい関数を作ります。
ファイル名は何でも良いはず?

ここに、トップレベルの関数を作って、@JsExportアノテーションを関数に付与すると、JavaScriptから呼び出すことが出来ます!!!

@OptIn(ExperimentalJsExport::class)
@JsExport
fun fixSeekableWebm() {
    // common で書いた処理を呼び出すなど
}

今回はこうです!!
WebMファイルのバイト配列を受け取って、Kotlin Multiplatform 製のシーク出来るようにする修正関数を呼び出し、バイト配列を返すという感じです。

エクスポートするには引数の制限があるので、こんな感じになりました。
詳しくはこのあとの説明で!

@OptIn(ExperimentalJsExport::class)
@JsExport
fun fixSeekableWebm(webmByteArray: JsArray<JsNumber>): JsArray<JsNumber> {
    val kotlinByteArray = webmByteArray.toList().map { it.toInt().toByte() }.toByteArray()
    val elementList = HimariWebmParser.parseWebmLowLevel(kotlinByteArray)
    val fixedByteArray = HimariFixSeekableWebm.fixSeekableWebm(elementList)
    val wasmFixedByteArray = fixedByteArray.map { it.toInt().toJsNumber() }.toJsArray()
    return wasmFixedByteArray
}

JavaScript と Kotlin のデータやり取り

プリミティブ型は渡せます。

@JsExport
fun fixSeekableWebm(int: Int, string: String) // OK
一方、配列やバイト配列は一筋縄では行きません。

Interoperability with JavaScript | Kotlin

https://kotlinlang.org/docs/wasm-js-interop.html

@JsExport
fun fixSeekableWebm(list: List<String>) // エラー

これは、JsArray<>を使うことで配列もJavaScriptから受け取ることが出来ます。

@JsExport
fun fixSeekableWebm(intList: JsArray<JsNumber>, stringList: JsArray<JsString>) {
    val kotlinIntList: List<Int> = intList.toList().map { it.toInt() }
    val kotlinStringList  = stringList.toList().map { it.toString() }
}

JsArrayは拡張関数があるので、相互変換は結構簡単かなと思います。Kotlinらしくてすきすき大好き~

val kotlinByteArray: ByteArray = webmByteArray.toList().map { it.toInt().toByte() }.toByteArray()
val wasmFixedByteArray: JsArray<JsNumber> = fixedByteArray.map { it.toInt().toJsNumber() }.toJsArray()

そして、個人的にすごいと思ったのが、JsReference<>です。
名前の通りこれは、Kotlin/Wasm側の値の参照を表現するクラスです。Kotlin/Wasmで使っている参照を表すためのものなので、JS側からはよく分からないオブジェクトとかになるはずです。

例えば、Kotlin/WasmKotlinのクラスの参照をJsReferenceで返します。
これは、JSの世界ではよく分からないオブジェクトですが、これをKotlin/Wasm側の関数に渡すと、参照からクラスを取得できるという魂胆です。

@JsExport
fun parseWebm(webmByteArray: JsArray<JsNumber>): JsReference<WebmParseResult> {
    return HimariWebmParser.parseWebm(webmByteArray.toByteArray()).toJsReference()
}

@JsExport
fun getVideoWidthFromWebmParseResult(reference: JsReference<WebmParseResult>): Int? {
    return reference.get().videoTrack?.videoWidth
}

これをJavaScriptで呼び出すと、Kotlin/Wasmで使っている値の参照がもらえます。
JS側ではこの参照を、参照を必要とする関数に渡すくらいしか使い道はないと思います。

呼び出されると、Kotlin/Wasm側の関数はget()で参照からインスタンスに戻せるって。

const parseRef = parseWebm(intArray)
const width: number = getVideoWidthFromWebmParseResult(parseRef)

もちろんJsArray<>JsReferenceを入れてもちゃんと動きます。
JsArray<JsReference<T>>

JavaScript で呼び出せるようにエクスポートする

ここからGradleのコマンドを入力するウィンドウを出します。

コマンド

そしたら、テキストフィールドに以下のコマンドを入力して、Enterします。

gradle wasmJsBrowserProductionRun

コマンド

これで待っているとnpm install出来るライブラリが出力されます。
パスは→build/wasm/packages/{outputModuleName}に存在するはずです。

dist

なんか見覚えあるフォルダ構成だなって

Configuration cache state could not be cached: field value of kotlin.reflect.jvm.internal.ReflectProperties$LazySoftVal bean found in field ...

なっがw

FAILURE: Build failed with an exception.

* What went wrong:
Configuration cache state could not be cached: field `value` of `kotlin.reflect.jvm.internal.ReflectProperties$LazySoftVal` bean found in field `descriptor$delegate` of `kotlin.reflect.jvm.internal.KClassImpl$Data` bean found in field `value` of `kotlin.InitializedLazyImpl` bean found in field `data` of `kotlin.reflect.jvm.internal.KClassImpl` bean found in field `nodeJsRootKlass` of `org.jetbrains.kotlin.gradle.targets.web.nodejs.NodeJsRootPluginApplier` bean found in field `this$0` of `org.jetbrains.kotlin.gradle.targets.web.nodejs.NodeJsRootPluginApplier$apply$nodeJsRoot$1` bean found in field `nodeJs` of `org.jetbrains.kotlin.gradle.targets.wasm.nodejs.WasmNodeJsRootExtension` bean found in field `nodeJsRoot` of `org.jetbrains.kotlin.gradle.targets.js.ir.KotlinBrowserJsIr` bean found in field `value` of `kotlin.InitializedLazyImpl` bean found in field `browserLazyDelegate` of `org.jetbrains.kotlin.gradle.targets.js.ir.KotlinJsIrTarget` bean found in field `$this_wasmJs` of `Build_gradle$1$1$3` bean found in field `__packageJsonHandlers__` of task `:library:wasmJsPackageJson` of type `org.jetbrains.kotlin.gradle.targets.js.npm.tasks.KotlinPackageJsonTask`: error writing value of type 'java.lang.ref.SoftReference'
> Unable to make field private long java.lang.ref.SoftReference.timestamp accessible: module java.base does not "opens java.lang.ref" to unnamed module @81439d1

よくわからないですが、GradleConfiguration Cacheが有効になってるとなぜか失敗する。
とりあえず成功させたいのでgradle.propertiesファイルを開き、以下のキーをfalseにした。

org.gradle.configuration-cache=false

Lock file was changed

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':kotlinWasmStoreYarnLock'.
> Lock file was changed. Run the `kotlinWasmUpgradeYarnLock` task to actualize lock file

何もしてないはずなので、kotlin-js-storeフォルダーを消しました。とりあえずは治りました。

npm install してみる!

React + ViteでもNext.jsでも何でも良いのですが、今回はReactUIを作り、Kotlin/Multiplatformの関数を呼び出そうかなと。
これはどっちでも良いと思います。なんならReact以外でも良いんじゃない?

  • Next.js+クライアントコンポーネントで Kotlin/Wasm の関数を呼び出す
    • ひと手間必要です
  • Vite + React
    • OK
    • コピーして使う分には問題ないはず

今回は一番手間なNext.js + クライアントコンポーネントでやってみます。
とにかくReactで試したい場合はViteがいいと思います。

というわけで新しいフォルダを作って、Next.jsのプロジェクトを作ります。
今回はTypeScript ONTailwindCSS ONAppRouter / src directory ONでいきます。

npx create-next-app@latest

こまんど

適当にフロントエンドのプロジェクトが出来たら、さっき作ったKotlin/Wasmのライブラリを入れます。
フロントエンドのフォルダに、さっきのKotlin/Wasm 製ライブラリをコピーして、ファイルパスを指定してnpm installをします。

コピー

インストール

インポートして使ってみる

こんな感じにフロント側で、適当なTypeScriptを開いて(今回はpage,tsx)、Kotlin/Wasmでエクスポートした関数名を入力すると、VSCodeが補完を表示するはず。
もし出なくても、import { } from "ライブラリ名"すれば取り込めるはず。

補完

すごい!!!

import { fixSeekableWebm } from "fix-seekable-webm-kotlin-wasm";

export default function Home() {
    fixSeekableWebm()
}

実際に使ってみる

今回はMediaRecorderWebMをシークできるようにするコードなので、MediaRecorderから書いていきます。
もう面倒なのが理由ですが、全部一つのファイルに書いたほうが読む側は楽かな~って

今回はNext.js+TailwindCSSを使っています。
録画開始ボタン・終了ボタン。それからWebMファイルを受け付けるボタンをおいています。
MediaRecorderの画面録画は多分調べたら出てくるので省略します。

画面録画するとBlob[]が取得できます。配列の中身を全部つなげるとWebMファイルになるという感じです。
なんとかしてArrayBufferに変換します。これをInt8Array()とかに渡すと無事バイト配列として操作できるというわけです。
このバイト配列をKotlin/Wasmの関数渡します。型エラーになるので無理やりキャストして渡しています。一応動いてます。
あとは、この関数の返り値をいい感じにInt8Array -> ArrayBuffer -> BlobUrlにすることでダウンロードができます。

await import("fix-seekable-webm-kotlin-wasm")しているのは後述します。

"use client"

import { useRef, useState } from "react";

export default function Home() {
  const chunkList = useRef<Blob[]>([])
  const mediaRecorder = useRef<MediaRecorder>(null)
  const mediaStream = useRef<MediaStream>(null)
  const [isRunning, setRunning] = useState(false)

  async function handleStartClick() {
    mediaStream.current = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: { channelCount: 2 } });
    mediaRecorder.current = new MediaRecorder(mediaStream.current, { mimeType: 'video/webm; codecs="vp9"' })
    mediaRecorder.current.ondataavailable = (event) => {
      chunkList.current.push(event.data);
    }
    mediaRecorder.current.start(100)
  }

  async function handleStopClick() {
    // 終了させる
    mediaRecorder.current?.stop()
    mediaStream.current?.getTracks().forEach((track) => track.stop())
    // シークできるようにする関数
    const webmArrayBuffer = await (new Blob(chunkList.current, { type: "video/webm" })).arrayBuffer()
    startFixSeekableWebmLibAndDownload(webmArrayBuffer)
    // リセット
    chunkList.current.splice(0)
  }

  async function startFixSeekableWebmLibAndDownload(arrayBuffer: ArrayBuffer) {
    // UI に反映
    setRunning(true)
    // Kotlin/Wasm の JsArray<Number> にする
    const intArray = new Int8Array(arrayBuffer)
    // Kotlin/Wasm でエクスポートした関数を呼び出す
    // 今のところ遅延ロードでクライアントからインポートする必要がありそう
    // window オブジェクトが使えるかの判定を内部でしている雰囲気
    const { fixSeekableWebm } = await import("fix-seekable-webm-kotlin-wasm")
    const fixSeekableWebmNumberList = fixSeekableWebm(intArray as any)
    const fixSeekableWebmIntArray = new Int8Array(fixSeekableWebmNumberList as any)
    // ダウンロード
    const downloadBlob = new Blob([fixSeekableWebmIntArray], { type: "video/webm" })
    const blobUrl = URL.createObjectURL(downloadBlob)
    // <a> タグを作って押す
    const anchor = document.createElement("a") as HTMLAnchorElement
    anchor.href = blobUrl
    anchor.download = `fix-seekable-webm-kotlin-wasm-${Date.now()}.webm`
    document.body?.appendChild(anchor)
    anchor.click()
    anchor.remove()
    // 戻す
    setRunning(false)
  }

  async function handleWebmFilePicker(event: React.ChangeEvent<HTMLInputElement>) {
    // 取得
    const webmFile = event.currentTarget.files?.[0]
    if (!webmFile) return
    // 処理開始
    const webmArrayBuffer = await webmFile.arrayBuffer()
    startFixSeekableWebmLibAndDownload(webmArrayBuffer)
  }

  return (
    <div className="flex flex-col items-start space-y-2">

      <h1>JavaScript MediaRecorder で出力される WebM をシークできるようにする Kotlin/Wasm</h1>
      <h2>Kotlin/Wasm npm install して Next.js から使っています。</h2>

      {
        isRunning && (
          <p className="text-red-400">
            WebM ファイルをシークできるように修正中ですすこし時間がかかります
          </p>
        )
      }

      <div className="flex flex-col p-2 border-2 border-blue-400 rounded-lg">
        <h3>MediaRecorder 録画開始終了</h3>
        <button className="border-2" onClick={handleStartClick}>録画開始</button>
        <button className="border-2" onClick={handleStopClick}>録画終了</button>
      </div>

      <div className="flex flex-col p-2 border-2 border-blue-400 rounded-lg">
        <h3>すでにある WebM ファイルをシークできるようにする</h3>
        <input
          className="border-2"
          type="file"
          accept=".webm"
          onChange={handleWebmFilePicker} />
      </div>
    </div>
  )
}

スクリーンショットです。

UI

Next.js のクライアントコンポーネントで呼び出して使う

Kotlin/Wasm の JS コード修正

ちなみに、Vite+Reactではこの問題は起きません。processオブジェクトがそもそもないので。

一方、Next.jsのクライアントコンポーネントから使おうとすると、このエラーが発生します。

Error: Cannot read properties of undefined (reading 'name')

調べた結果、Next.jsのサーバーコンポーネントの場合、グローバル変数のprocessprocess.release.nameの返り値が"node"なんですが、
クライアントコンポーネントでは、processオブジェクトは存在するものの、releaseオブジェクトが存在しないのでエラーになる。

const isNodeJs = (typeof process !== 'undefined') && (process.release.name === 'node');

なので、こうやってtruthyの値かの判定を入れるか、オプショナルチェーンするか。
今のところ、Kotlinが出力したファイルを手直しするのが一番早そう。手直しするのが一番ダメそうな案だけど。

// truthy
const isNodeJs = (typeof process !== 'undefined') && process.release && (process.release.name === 'node');

// もしくは optional chaining
const isNodeJs = (typeof process !== 'undefined') && (process.release?.name === 'node');

遅延読み込みさせる

これもVite+Reactの場合は問題になりません。良くも悪くもブラウザ環境でしか描画されないためです。

Kotlin/Wasm.wasmをロードする際、今の環境がサーバーかブラウザかでロード処理を分けています。
クライアントコンポーネントでKotlin/Wasmの関数を呼び出して使う場合、ブラウザ環境で動かすことになります。

で、そのブラウザ環境かの判定にwindowオブジェクトがグローバル変数に存在するか。で判定しています。
Next.jsを使ったことある方、もうお分かりですね。

typeof window !== 'undefined'

Next.jsは御存知の通り、サーバー(Node.js環境)で一回Reactが描画されてから、ブラウザへ送信されます。
Next.jsの強みです。

これがブラウザ環境で描画する場合、ブラウザ由来のAPIwindowdocumentが存在します。よってこの判定は問題なく動くわけですね。
一方Node.js環境で描画する場合、ブラウザ由来のAPIは存在しないため、判定結果がNode.js、でなります。いやNode.js環境だってんだからそりゃそうだろ。

これを回避し、常にブラウザ環境でロードする方法があります。どっちかと言うとギリギリまでライブラリのロードを遅らせる。が正しいですね。
遅延ロードって方法ですね。windowundefined以外であることが保証されているはずです。

Guides: Lazy Loading | Next.js

Lazy load imported libraries and React Components to improve your application's loading performance.

今回はこれを使うことで、確実に(?)ブラウザ側でしかライブラリがロードしないようになりました。
必要になるまでロードしないため、高速化にもつながったはずです。

というわけで、このインポートを消して

import { fixSeekableWebm } from "fix-seekable-webm-kotlin-wasm"

代わりに必要なタイミングでawait import()するように修正しました

async function startFixSeekableWebmLibAndDownload(arrayBuffer: ArrayBuffer) {
    // 必要になるまでライブラリを import しない
    const { fixSeekableWebm } = await import("fix-seekable-webm-kotlin-wasm")
    const fixSeekableWebmNumberList = fixSeekableWebm(intArray as any)
}

完成!

Kotlin/Wasmで作った関数をJavaScriptで呼び出して使うことが出来ました!!!
ちゃんと録画した動画がシークできるようになってます!

トップ

録画開始

保存

シークできる!

ソースコード

今回の例で使ったものたち

冒頭のGitHubからnpm installできるnpm ライブラリKotlin Multiplatform

おわりに

シークできる処理はKotlinで書かれていて、Kotlin/JSしています。
一方、Kotlin/JSではReactなんかは使えない(使えたところで Kotlin で書くのは・・・)ため、見た目は調整せずHTMLを書いただけになっていました。

ですが、シークできる処理がnpm ライブラリになったことで、見た目はReactで書いて、シークする処理はKotlin/Wasmのライブラリを読み込むだけになった。
見た目はやっぱりReactとかで作りたい。

それと、JavaScript前提のブラウザ APIKotlinの文法で書くのは、、、コレジャナイ感ある。
すべてのブラウザ APIKotlin/JSで呼び出せる、、わけじゃない。
一部のAPIは用意されてないので、js()JavaScriptのコードを文字列で渡していて、やっぱJavaScript向けのAPIはその言語で書くべきだよなって。

js(" ここに JS コード ")←これがよく動いているのはすごいけど、、

おわりに2

Kotlin Multiplatformするとホームディレクトリに地味にでかいフォルダが生成されます。

1GB超え

おわりに3

今回使ったこれ、メモリに優しくない(めっちゃ消費する)ので、イマイチです。