LoginSignup
3
2

式の余興に「AWS+GASシステム」で超盛り上がった件 〜①画面編〜

Last updated at Posted at 2023-12-28

「式の余興に「AWS+GASシステム」で超盛り上がった件 〜はじまり〜」の続きになります。
前回記事を読んでいない人はぜひ読んでください。お願いします!!
今回の記事は主に、システムの構成と画面周りの実装に焦点を当てています。

🙋‍♂️プランナーさんに確認

そもそも自作したとして、会場側から許可が降りるのか会場スクリーンにどのように出力できるのかを確認しなければ、作る意味もないし、何も決められません。

とりあえずプランナーさんに確認してしたところ、二つ返事で許可が降りました
会場スクリーンの出力はHDMIに対応しているようでした。
意外にも、パソコンを利用した余興は一般的らしいです。
※どんな余興をしてるんだろう。。すごい気になる....

さっそく、システム構成を考えてみました。

📏システム構成

Webアプリケーションでの実装としました。

システム構成図.png

メイン処理は全てGASで実装を行いました。
投稿画面(①)とスクリーン画面(⑤)のHTMLを2つ、GASと紐付けたスプレッドシートが1つある状態です。
写真が投稿されるたびに、Googleドライブにアップロードされ、共有リンクがスプレッドシートに追記されます。
そしてEC2インスタンス上に稼働してあるFastAPIへフックし、画像内に映った人物の表情を数値化、笑顔数値のみを返却してもらいます。
あとは、スプレッドシートの情報を定期的にスクリーン画面(⑤)がポーリングすることで、スクリーン画面に写真と笑顔数値が表示されるという流れになります。

この構成でいけば、ほとんどお金がかからないハズです。
そう信じて画面から作成していきました。

💻フロント側

結婚式のスクリーンに流れるものなので、なんといっても見栄えが大事です。
会場の雰囲気に合わないデザインや配色だと、浮いて見えてしまうのは容易に想像できます。
しかし、実際に会場でテストしながら開発することはできないため、イメージをもとに制作しました。
(※あとはブライダルフェアの写真を参考にしてみたりも...)

🌃スクリーン画面(会場)

サンプルコード

めちゃめちゃに長いです...
アニメーションや動的制御だらけでやっつけコーディングになっています。
どうか温かい目でご覧ください🥺

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Smileys</title>
  <link rel="icon" href="/favicon.ico" sizes="any" />
  <link rel="stylesheet" href="style.css">
</head>

<body>
  <main class="content">
    <div class="photo-shower-container">
      <img id="finishFlags" src="./flag-dot.png" />
      <img id="cracker" src="./cracker.png" />
      <div class="photo-shower-back"></div>
      <div id="inputField">
        <button class="inputButton" id="inputStartButton" onclick="onClickStart()">
          Start
        </button>
        <button class="inputButton" id="inputPauseButton" onclick="onClickPause()" disabled>
          Pause
        </button>
        <button class="inputButton" id="inputEndButton" onclick="onClickEnd()" disabled>
          End
        </button>
        <span class="range-label">Fast</span>
        <input type="range" id="inputSliderResult" class="input-range" min="1" max="10" value="5" />
        <span class="range-label">Slow</span>
      </div>
    </div>
  </main>
</body>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="main.js"></script>

</html>
style.css
/* グローバル変数を定義。フェードイン時の移動距離と写真アニメーションの期間。 */
:root {
    /* フェードイン時の降下値 */
    --height: -50%;
    --pictureAnimate: 20s;
}

/* フルスクリーンの背景コンテナ。画面全体を覆う大きさで、オーバーフローは隠す。背景にグラデーションを設定し、アニメーションで動きを付ける。 */
.photo-shower-container {
    position: relative;
    height: 100vh;
    /* コンテナの高さ */
    width: 100%;
    /* コンテナの横幅 */
    overflow: hidden;
    /* コンテナからはみ出した要素を隠す */
    background-image: linear-gradient(to right, #000b1a 0%, #0b193c 100%);
    background-size: 200% 200%;
    /*サイズを大きくひきのばす*/
    animation: bggradient 5s linear infinite;
}

/* コンテナの背景。高さと幅は画面全体。 */
.photo-shower-back {
    height: 100vh;
    /* コンテナの高さ */
    width: 100%;
    /* コンテナの横幅 */
}

/* 背景のグラデーションアニメーション。位置を変えて動きを表現。 */
@keyframes bggradient {
    0% {
        background-position: 0% 50%;
    }

    50% {
        background-position: 100% 50%;
    }

    100% {
        background-position: 0% 50%;
    }
}

/* 影を持つ背景要素。 */
.backgound {
    box-shadow: 0px 0px 20px 5px rgba(255, 255, 255, 0.5);
}

/* フェードアップアニメーション。位置や変形を調整。 */
.fadeUp {
    position: absolute;
    /* ここは動的に変更したい */
    left: 30%;
    transform: translate(0, -50%);
    animation: fadeUpAnime 3s forwards;
}

/* フェードアップのキーフレームアニメーション。 */
@keyframes fadeUpAnime {
    from {
        top: -50%;
    }

    to {
        top: 50%;
        transform: translate(0, var(--height));
    }
}

/* 「ふわふわ」アニメーション。ゆっくりとした上下運動。 */
.anime-fuwafuwa {
    animation: 3s fuwafuwa infinite;
}

/* ふわふわアニメーションのキーフレーム。 */
@keyframes fuwafuwa {

    0%,
    100% {
        top: 50%;
        transform: translate(0, var(--height));
    }

    50% {
        transform: translate(5%, var(--height));
    }
}

/* フェードアウトアニメーション。透明度を変化させる。 */
.fadeout {
    animation: 3s fuwafuwa infinite, fadeout-anim 3s linear forwards;
}

/* フェードアウトのキーフレームアニメーション。 */
@keyframes fadeout-anim {
    100% {
        opacity: 0;
    }
}

/* 写真のスタイル設定。絶対位置指定、影、アニメーション。 */
.picture {
    position: absolute;
    top: 50%;
    box-shadow: 0px 0px 20px 5px rgba(255, 255, 255, 0.5);
    transform: translate(-50%, -50%);
    animation: animate-picture var(--pictureAnimate) ease-in-out;
}

/* 写真の最大サイズを制限。 */
.pic {
    max-width: 600px;
    max-height: 600px;
}

/* 写真の移動と変形を行うアニメーション。 */
@keyframes animate-picture {
    0% {
        left: -50%;
    }

    15% {
        transform: translate(-50%, -50%);
        left: 50%;
    }

    16% {
        transform: translate(-50%, -50%);
        left: 50%;
    }

    30% {
        transform: translate(-50%, -50%) scale(1.3);
        visibility: visible;
    }

    70% {
        transform: translate(-50%, -50%) scale(1.3);
    }

    85% {
        left: 50%;
        transform: translate(-50%, -50%) scale(1);
    }

    86% {
        left: 50%;
        transform: translate(-50%, -50%) scale(1);
    }

    100% {
        left: 50%;
        transform: translate(-50%, -300%);
    }
}

/* メインコンテンツの位置設定。 */
.content {
    position: relative;
}

/* スコア表示のスタイル。位置、フォントサイズ、色を設定。 */
.score {
    position: absolute;
    visibility: hidden;
    bottom: 0;
}

.num {
    position: absolute;
    top: 45px;
    font-size: 40px;
    left: 35px;
    color: red;
}

.percent {
    position: absolute;
    top: 70px;
    font-size: 16px;
    left: 90px;
}

/* フェードインアニメーションのキーフレーム。 */
.fadeIn {
    animation: fadeInAnime 500ms forwards;
    opacity: 0;
}

@keyframes fadeInAnime {
    0% {
        opacity: 0;
        transform: scale(1.4);
    }

    100% {
        opacity: 1;
        transform: scale(1);
    }
}

/* ボタンの状態に応じた透明度の変化。 */
button:hover {
    opacity: 0.9;
}

button:active {
    opacity: 0.6;
}

button:disabled {
    opacity: 0.4;
}

/* スライダーのスタイル。外観、背景色、サイズを設定。 */
.input-range[type="range"] {
    -webkit-appearance: none;
    appearance: none;
    background-color: #c7c7c7;
    height: 2px;
    width: 70%;
}

.input-range[type="range"]:focus {
    outline: none;
}

.input-range[type="range"]:active {
    outline: none;
}

.input-range[type="range"]:disabled {
    opacity: 0.4;
}

/* スライダーのつまみのスタイル。外観、位置、大きさ、背景色。 */
.input-range[type="range"]::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    cursor: pointer;
    position: relative;
    border: none;
    width: 16px;
    height: 16px;
    display: block;
    background-color: #ffffff;
    border-radius: 50%;
    -webkit-border-radius: 50%;
}

/* スライダーのラベルスタイル。色、余白を設定。 */
.range-label {
    color: #fff;
    margin: 5px;
}

/* 入力フィールドのスタイル。位置、幅を設定。 */
#inputField {
    position: absolute;
    bottom: 30px;
    left: 4%;
    width: 100%;
}

/* 入力ボタンのスタイル。背景、境界線、色、フォント、角の丸み、余白。 */
.inputButton {
    background: transparent;
    border: 2px solid #fff;
    color: #fff;
    font-weight: bold;
    border-radius: 5px;
    margin-right: 24px;
}

/* 終了時の白いレイヤー。位置、サイズ、背景色、アニメーション。 */
.finishLayer {
    position: absolute;
    top: 0%;
    left: 0%;
    width: 100%;
    height: 100%;
    background-color: rgba(255, 255, 255, 0);
    /* 初期の透過度を1に設定 */
    animation: white 3s ease-in-out forwards;
}

/* 白いレイヤーのアニメーション。透明度の変化。 */
@keyframes white {
    0% {
        background-color: rgba(255, 255, 255, 0);
        /* 透過度を0に変更 */
    }

    100% {
        background-color: rgba(255, 255, 255, 0.15);
        /* 透過度を1に変更 */
    }
}

/* 終了時の写真スタイル。位置、影、変形、アニメーション。 */
.end-picture {
    position: absolute;
    top: 50%;
    box-shadow: 0px 0px 20px 5px rgba(255, 255, 255, 0.5);
    transform: translate(-50%, -50%);
    animation: end-animate-picture var(--pictureAnimate) ease-in-out forwards;
}

/* 終了時の写真アニメーション。 */
@keyframes end-animate-picture {
    0% {
        left: -50%;
    }

    15% {
        transform: translate(-50%, -50%);
        left: 50%;
    }

    16% {
        transform: translate(-50%, -50%);
        left: 50%;
    }

    30% {
        transform: translate(-50%, -50%) scale(1.3);
        visibility: visible;
    }

    70% {
        transform: translate(-50%, -50%) scale(1.3);
    }

    100% {
        left: 50%;
        transform: translate(-50%, -50%) scale(1.3);
    }
}

/* 結果発表の画像スタイル。サイズ、位置、表示状態。 */
#finishFlags {
    width: 100%;
    z-index: 1;
    position: absolute;
    display: none;
}

/* 結果発表の画像スタイル。サイズ、位置、表示状態。 */
#cracker {
    position: absolute;
    z-index: 2;
    width: 100%;
    bottom: 10px;
    display: none;
}
main.js

// 背景に映る画像のリスト
let letbackgroundimageList = [];
// 写真生成インターバル
let createPicInterval = 20000;
// バッチ生成インターバル
let createBadgeInterval = 6500;
// 笑顔数値の高い画像を格納
let smileMax = [];
// 初期表示時のstarボタン制御フラグ
let readyFlg = true;
// はじめに表示される写真たち
let image_list = [
    {
        // 画像のURL
        url: "https://1.bp.blogspot.com/-l4fWuSze_MI/YHDkJRsVYzI/AAAAAAABdlM/4lid3iHq_aMFybNb9PYCOpNIEtOwgwRFwCNcBGAsYHQ/s755/hengao_mabuta_uragaesu.png",
        // 笑顔データ(x座標,y座標,数値)
        smile: "[[1853.485390625,433.0812413641747,0.7354134249687195]]",
    },
];

let inputSliderResult = document.querySelector("#inputSliderResult");

let section = document.querySelector(".photo-shower-container");
// 左右交互に写真を表示させるため
let field = "right";
// 表示画像リストの指標
let count = 0;
// どこまで表示したかを表す指標
let showIndicatorCount = 1000;
// 戻り状態の可否(一周して戻っているか)
let backflg = false;
// 戻り状態から復帰する際の退避指標
let backedShowIndicatorCount = 0;

let createId = null;

// 画像の配置順番
let directionCount = 0;

// 写真を生成する関数
function createPic() {
    // image_listを更新されているかチェック
    if ((showIndicatorCount < image_list.length) & backflg) {
        // 現状の指標を退避
        backedShowIndicatorCount = count;
        // 退避していた指標を取り出す
        count = showIndicatorCount;
        // 戻り状態を初期化
        backflg = false;
    }

    // 写真コンポーネントの生成
    const pictureEl = document.createElement("span");
    // 写真タグの生成
    let image = document.createElement("img");
    image.addEventListener("load", (e) => {
        pictureEl.className = "picture";
    });
    image.className = "pic";
    image.src = image_list[count]["url"];
    const smile_json = image_list[count]["smile"];

    // 写真の長辺の長さを指定
    const minSize = 800;
    const maxSize = 1000;
    let size = Math.random() * (maxSize + 1 - minSize) + minSize;

    // ここで写真のむきを取得。
    getPictureDirection(image.src)
        // dataにはwidthかheight。長い方がstringで入っている。
        .then((data) => {
            if (data === "width") {
                image.style.width = size + "px";
                image.style.height = "auto";
            } else {
                image.style.height = size + "px";
                image.style.weight = "auto";
            }
            pictureEl.appendChild(image);

            // スコアの生成
            const parsed = JSON.parse(smile_json);
            for (i = 0; i < parsed.length; i++) {
                // スコア画像の生成
                const panel = document.createElement("img");
                panel.src = "panel.png";
                panel.style.width = "130px";

                // スコアパーセントの生成
                const percent = document.createElement("p");
                percent.className = "percent";
                percent.textContent = "%";
                // スコアコンポーネントの生成
                const score = document.createElement("div");
                score.className = "score";

                score.appendChild(percent);
                const num = document.createElement("p");
                num.className = "num";
                const newContent = document.createTextNode(
                    Math.round(parsed[i][2] * 100)
                );
                num.appendChild(newContent);
                score.appendChild(num);
                score.appendChild(panel);
                score.style.left = parsed[i][0];
                score.style.top = parsed[i][1];

                pictureEl.appendChild(score);
            }

            // 左右の順番に写真が表示されるように
            if (field === "right") {
                field = "left";
            } else {
                field = "right";
            }
            section.appendChild(pictureEl);
        })
        .catch((err) => {
            console.log("error", err);
        });

    // 一定時間が経てば写真を消す
    setTimeout(() => {
        pictureEl.remove();
    }, createPicInterval);

    // 全て背景にしたら、背景画像の追加を行わない
    if (letbackgroundimageList.length < image_list.length) {
        setTimeout(() => {
            createBackPic();
        }, createPicInterval - 1000);
    }

    // バッジの表示タイミング
    setTimeout(() => {
        const select = document.getElementsByClassName("score");
        Array.prototype.forEach.call(select, function (item) {
            item.style.visibility = "visible";
            item.classList.add("fadeIn");
        });
    }, createBadgeInterval);

    // リストの最初に戻っていた場合は追加しない
    if (image_list.length > letbackgroundimageList.length) {
        letbackgroundimageList.push(image_list[count]["url"]);
    }

    count += 1;

    // image_listを全て表示したらもとに戻る
    if (count >= image_list.length) {
        // 一旦現状の指標を退避
        showIndicatorCount = count;
        // 退避していた指標を設定
        count = backedShowIndicatorCount;
        // 戻り状態をONへ
        backflg = true;
    }
}
// 写真を読み込んでから向きの判定
const loadImg = function (src) {
    return new Promise(function (resolve, reject) {
        const image = new Image();
        image.src = src;
        image.onload = function () {
            resolve(image);
        };
        image.onerror = function (error) {
            reject(error);
        };
    });
};

// 写真の向きを取得
let getPictureDirection = function getDirection(src) {
    return new Promise(function (resolve, reject) {
        loadImg(src)
            //読み込みが完了した(画像が設定された)Imageオブジェクトを受け取って処理
            .then(function (res) {
                if (res.width > res.height) {
                    resolve("width");
                } else {
                    resolve("height");
                }
            })
            //読み込みエラー時の処理
            .catch(function (error) {
                console.log(error);
            });
    });
};

// 上下左右中央に配置する位置の取得
function getInclent() {
    directionCount++;
    if (directionCount > 5) {
        directionCount = 1;
    }
    return directionCount;
}

// 背景写真を生成する関数
function createBackPic() {

    // 写真コンポーネントの生成
    const pictureEl = document.createElement("span");
    pictureEl.className = "backgound";

    // 左右は0%〜70%
    // 上下は-140%〜10%
    pictureEl.classList.add("fadeUp");
    switch (getInclent()) {
        case 1:
            // 左上
            pictureEl.style.left = getRandomInt(0, 30) + "%";
            pictureEl.style.setProperty(
                "--height",
                getRandomInt(-160, -85) + "%"
            );
            break;
        case 2:
            // 右上
            pictureEl.style.left = getRandomInt(50, 80) + "%";
            pictureEl.style.setProperty(
                "--height",
                getRandomInt(-160, -85) + "%"
            );
            break;
        case 3:
            // 右下
            pictureEl.style.left = getRandomInt(50, 80) + "%";
            pictureEl.style.setProperty("--height", getRandomInt(-20, 10) + "%");
            break;
        case 4:
            // 左下
            pictureEl.style.left = getRandomInt(0, 30) + "%";
            pictureEl.style.setProperty("--height", getRandomInt(-20, 10) + "%");
            break;
        default:
            // 中央
            pictureEl.style.left = getRandomInt(30, 40) + "%";
            pictureEl.style.setProperty("--height", getRandomInt(-95, -30) + "%");
    }
    pictureEl.addEventListener("animationend", () => {
        // アニメーション終了後に実行する内容
        pictureEl.classList.add("anime-fuwafuwa");
    });

    // 写真タグの生成
    let image = document.createElement("img");
    image.className = "pic";
    image.src = letbackgroundimageList[letbackgroundimageList.length - 1];

    // 写真の長辺の長さを指定
    const minSize = 200;
    const maxSize = 350;
    let size = Math.random() * (maxSize + 1 - minSize) + minSize;

    // ここで写真のむきを取得。
    getPictureDirection(image.src)
        // dataにはwidthかheight。長い方がstringで入っている。
        .then((data) => {
            if (data === "width") {
                image.style.width = size + "px";
                image.style.height = "auto";
            } else {
                image.style.height = size + "px";
                image.style.weight = "auto";
            }
            pictureEl.appendChild(image);

            // 左右の順番に写真が表示されるように
            if (field === "right") {
                field = "left";
            } else {
                field = "right";
            }
            section.appendChild(pictureEl);
        })
        .catch((err) => {
            console.log("error", err);
        });
}

// スライダーのリスナー
inputSliderResult.addEventListener("input", (e) => {
    // 20秒をベースに10メモリ分の初期値が5なので、1メモリあたり4秒計算
    const baseSpeed = 4;
    const newSpeed = Number(e.target.value) * baseSpeed;

    // 写真生成インターバルの変更
    createPicInterval = newSpeed * 1000;
    // バッチインターバルの変更
    createBadgeInterval = Math.ceil(createPicInterval / 3);

    // CSSアニメーションの変更
    const root = document.querySelector(":root");
    root.style.setProperty("--pictureAnimate", newSpeed + "s");
});

// -140から10までのランダムな整数を生成する関数
function getRandomInt(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
}

// リザルトの関数
function onClickStart() {
    disabledSliderResult();
    disabledStartButton();
    disabledEndutton();
    enabledPauseButton();

    // 写真を生成する間隔をミリ秒で指定
    createId = setInterval(function () {
        createPic();
    }, createPicInterval);

    // 初回だけ待たない
    createPic();
}

// ポーズボタン押下時の処理
function onClickPause() {
    disabledPauseButton();
    clearInterval(createId);
    enabledStartButton();
    enabledSliderResult();
    enabledEndutton();
}

// スライダー有効化
function enabledSliderResult() {
    const inputSliderResult = document.querySelector("#inputSliderResult");
    inputSliderResult.disabled = false;
}

// スライダー無効化
function disabledSliderResult() {
    const inputSliderResult = document.querySelector("#inputSliderResult");
    inputSliderResult.disabled = true;
}

// スタートボタン有効化
function enabledStartButton() {
    const inputStartButton = document.querySelector("#inputStartButton");
    inputStartButton.style.background = "transparent";
    inputStartButton.style.color = "#FFF";
    inputStartButton.disabled = false;
}

// スタートボタン無効化
function disabledStartButton() {
    const inputStartButton = document.querySelector("#inputStartButton");
    inputStartButton.style.background = "#FFF";
    inputStartButton.style.color = "#000";
    inputStartButton.disabled = true;
}

// ポーズボタン有効化
function enabledPauseButton() {
    const inputPauseButton = document.querySelector("#inputPauseButton");
    inputPauseButton.style.background = "transparent";
    inputPauseButton.style.color = "#FFF";
    inputPauseButton.disabled = false;
}

// ポーズボタン無効化
function disabledPauseButton() {
    const inputPauseButton = document.querySelector("#inputPauseButton");
    inputPauseButton.style.background = "#FFF";
    inputPauseButton.style.color = "#000";
    inputPauseButton.disabled = true;
}

// エンドボタン有効化
function enabledEndutton() {
    const inputEndButton = document.querySelector("#inputEndButton");
    inputEndButton.style.background = "transparent";
    inputEndButton.style.color = "#FFF";
    inputEndButton.disabled = false;
}

// エンドボタン有効化
function disabledEndutton() {
    const inputEndButton = document.querySelector("#inputEndButton");
    inputEndButton.style.background = "transparent";
    inputEndButton.style.color = "#FFF";
    inputEndButton.disabled = true;
}

// データの取得
function fetchData() {
    // XMLHttpRequestオブジェクトを作成
    let xhr = new XMLHttpRequest();

    // リクエストの設定
    xhr.open("GET", BACKEND_URL, true);

    // リクエストが完了したときの処理
    xhr.onload = function () {
        if (xhr.status === 200) {
            // レスポンスが成功の場合の処理
            image_list = JSON.parse(xhr.responseText);
            if (readyFlg) {
                enabledStartButton();
                readyFlg = false;
            }
        } else {
            // エラーが発生した場合の処理
            console.error("リクエストエラー:", xhr.status);
        }
    };

    // リクエスト送信
    xhr.send();
}

流れる画像を会場内で制御する必要があるので、3つのボタン(Start・Pause・End)を配置しました。
こちらのボタンで、スライドショーの開始や停止、結果発表を操作することになります。
当初は考えていなかったのですが、流れるスピードを会場内で調整できた方がいいかもなーと思い、スライダーも作ってみました...が、実際は結婚式当日の出番はありませんでした。

※以下のイメージでは、スライダーを「速め」に設定しています。速すぎる...

smileys.gif

画像の配列と笑顔数値をオブジェクト配列として管理しております。

バックエンドのGASに対してポーリングを行い、配列を更新することで常に最新の写真がスクリーンに映し出されます。
※笑顔データが二次元配列になっているのは気にしないでください (後述します)

[
    {
        // 画像のURL
        url: "XXXXXXX",
        // 笑顔データ(x座標,y座標,数値)
        smile: "[[1853.485390625,433.0812413641747,0.7354134249687195]]",
    },
    ・・・
]

📱投稿画面(スマートフォン)

サンプルコード

GASから配信されるHTMLなので、CSSやJavaScriptはHTMLファイルにすべて内包しています。
書くブラウザの差異を吸収するためにリセットCSSまでぶち込んでいますので、少し読みにくいかもしれません。

index.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="mobile-web-app-capable" content="yes" />
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <title>Wedding_photo_shower(11.4)</title>
    <link
      rel="stylesheet"
      href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
      integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
      crossorigin="anonymous"
    />
    <style>
      /* リセットCSS */
      *,
      *::before,
      *::after {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }

      :where([hidden]:not([hidden="until-found"])) {
        display: none !important;
      }

      :where(html) {
        -webkit-text-size-adjust: none;
        color-scheme: dark light;
      }

      @media (prefers-reduced-motion: no-preference) {
        :where(html:focus-within) {
          scroll-behavior: smooth;
        }
      }

      :where(body) {
        line-height: 1.5;
        font-family: system-ui, sans-serif;
        -webkit-font-smoothing: antialiased;
      }

      :where(input, button, textarea, select) {
        font: inherit;
        color: inherit;
      }

      :where(textarea) {
        resize: vertical;
        resize: block;
      }

      :where(button, label, select, summary, [role="button"], [role="option"]) {
        cursor: pointer;
      }

      :where(:disabled) {
        cursor: not-allowed;
      }

      :where(label:has(> input:disabled), label:has(+ input:disabled)) {
        cursor: not-allowed;
      }

      :where(button) {
        border-style: solid;
      }

      :where(a) {
        color: inherit;
        text-underline-offset: 0.2ex;
      }

      :where(ul, ol) {
        list-style: none;
      }

      :where(img, svg, video, canvas, audio, iframe, embed, object) {
        display: block;
      }

      :where(img, picture, svg) {
        max-inline-size: 100%;
        block-size: auto;
      }

      :where(p, h1, h2, h3, h4, h5, h6) {
        overflow-wrap: break-word;
      }

      :where(h1, h2, h3) {
        line-height: calc(1em + 0.5rem);
      }

      :where(hr) {
        border: none;
        border-block-start: 1px solid;
        color: inherit;
        block-size: 0;
        overflow: visible;
      }

      :where(:focus-visible) {
        outline: 3px solid Highlight;
        outline-offset: 2px;
        scroll-margin-block: 10vh;
      }

      :where(.visually-hidden:not(:focus-within, :active)) {
        clip-path: inset(50%) !important;
        height: 1px !important;
        width: 1px !important;
        overflow: hidden !important;
        position: absolute !important;
        white-space: nowrap !important;
        border: 0 !important;
      }

      /* リセットCSS ここまで */

      body {
        background-color: #fddee5;
        display: grid;
        grid-template-rows: auto 1fr auto;
        min-height: 100dvh;
        height: 100dvh;
      }

      header,
      footer {
        height: 20px;
        background-color: rgb(28 63 203 / 64%);
        color: #fff;
        font-size: 12px;
        text-align: center;
      }

      .circle {
        margin-top: 8%;
        margin-right: auto;
        margin-left: auto;
        background-color: rgb(28 63 203 / 64%);
        width: 170px;
        height: 170px;
        display: flow;
        border-radius: 50%;
        border: dotted #fefefe 3px;
        border-width: 1.5px;
        text-align: center;
      }

      .circle-content {
        padding-top: 38px;
        color: #fff;
      }

      @import url("https://fonts.googleapis.com/css2?family=Caveat&display=swap");

      .text {
        font-size: 4.5vw;
        line-height: 1em;
        font-family: "Cormorant Garamond", serif;
      }

      .small-text {
        font-size: 4vw;
      }

      @import url("https://fonts.googleapis.com/css2?family=Itim&display=swap");

      #date {
        line-height: 1.1em;
        font-size: 2.7em;
        font-family: "Itim", cursive;
      }

      .btn--orange,
      a.btn--orange {
        color: #fff;
        background-color: #eb6100;
      }

      .btn--orange:hover,
      a.btn--orange:hover {
        color: #fff;
        background: #f56500;
      }

      a.btn--radius {
        border-radius: 100vh;
      }

      #pic {
        display: block;
        width: 80%;
        margin: 10svh auto 0;
        padding: 10px 0;
        text-align: center;
        border: 0;
        box-shadow: none;
        color: #ffffff;
        font-weight: bold;
        font-size: 1rem;
        background-color: #f59827;
        cursor: pointer;
      }

      #submitLabel {
        display: block;
        width: 80%;
        margin: 16px auto 0;
        padding: 10px 0;
        text-align: center;
        border: 0;
        box-shadow: none;
        color: #ffffff;
        font-weight: bold;
        font-size: 1rem;
        background-color: #279cf5;
        cursor: pointer;
      }

      input {
        display: none;
      }

      content {
        position: absolute;
      }

      #backgroundimage {
        position: relative;
        opacity: 0.4;
        width: 60%;
        left: 50%;
        bottom: -5%;
        transform: translateX(-50%);
      }

      #picture {
        margin-top: 10%;
        margin-right: auto;
        margin-left: auto;
        width: 80%;
      }

      @keyframes sk-cubemove {
        25% {
          transform: translateX(42px) rotate(-90deg) scale(0.5);
        }
        50% {
          transform: translateX(42px) translateY(42px) rotate(-179deg);
        }
        50.1% {
          transform: translateX(42px) translateY(42px) rotate(-180deg);
        }
        75% {
          transform: translateX(0px) translateY(42px) rotate(-270deg) scale(0.5);
        }
        100% {
          transform: rotate(-360deg);
        }
      }

      .loader {
        position: absolute;
        top: 0;
        left: 0;
        z-index: 1;
        display: none;
        width: 100%;
        height: 100%;
        background-color: rgba(0, 0, 0, 0.5);
      }
      .loader__spinner {
        position: absolute;
        top: 50%;
        left: 50%;
        width: 57px;
        height: 57px;
        transform: translate(-50%, -50%);
      }
      .loader__spinner--cube1,
      .loader__spinner--cube2 {
        background-color: #ffffff;
        width: 15px;
        height: 15px;
        position: absolute;
        top: 0;
        left: 0;
        animation: sk-cubemove 2s infinite ease-in-out;
      }

      .loader__spinner--cube2 {
        animation-delay: -1s;
      }

      @media (prefers-color-scheme: dark) {
        body {
          background-color: #fddee5;
        }
      }
      /* リセットCSS */
      *,
      *::before,
      *::after {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }

      :where([hidden]:not([hidden="until-found"])) {
        display: none !important;
      }

      :where(html) {
        -webkit-text-size-adjust: none;
        color-scheme: dark light;
      }

      @media (prefers-reduced-motion: no-preference) {
        :where(html:focus-within) {
          scroll-behavior: smooth;
        }
      }

      :where(body) {
        line-height: 1.5;
        font-family: system-ui, sans-serif;
        -webkit-font-smoothing: antialiased;
      }

      :where(input, button, textarea, select) {
        font: inherit;
        color: inherit;
      }

      :where(textarea) {
        resize: vertical;
        resize: block;
      }

      :where(button, label, select, summary, [role="button"], [role="option"]) {
        cursor: pointer;
      }

      :where(:disabled) {
        cursor: not-allowed;
      }

      :where(label:has(> input:disabled), label:has(+ input:disabled)) {
        cursor: not-allowed;
      }

      :where(button) {
        border-style: solid;
      }

      :where(a) {
        color: inherit;
        text-underline-offset: 0.2ex;
      }

      :where(ul, ol) {
        list-style: none;
      }

      :where(img, svg, video, canvas, audio, iframe, embed, object) {
        display: block;
      }

      :where(img, picture, svg) {
        max-inline-size: 100%;
        block-size: auto;
      }

      :where(p, h1, h2, h3, h4, h5, h6) {
        overflow-wrap: break-word;
      }

      :where(h1, h2, h3) {
        line-height: calc(1em + 0.5rem);
      }

      :where(hr) {
        border: none;
        border-block-start: 1px solid;
        color: inherit;
        block-size: 0;
        overflow: visible;
      }

      :where(:focus-visible) {
        outline: 3px solid Highlight;
        outline-offset: 2px;
        scroll-margin-block: 10vh;
      }

      :where(.visually-hidden:not(:focus-within, :active)) {
        clip-path: inset(50%) !important;
        height: 1px !important;
        width: 1px !important;
        overflow: hidden !important;
        position: absolute !important;
        white-space: nowrap !important;
        border: 0 !important;
      }

      /* リセットCSS ここまで */

      body {
        background-color: #fddee5;
        display: grid;
        grid-template-rows: auto 1fr auto;
        min-height: 100dvh;
        height: 100dvh;
      }

      header,
      footer {
        height: 20px;
        background-color: rgb(28 63 203 / 64%);
        color: #fff;
        font-size: 12px;
        text-align: center;
      }

      .circle {
        margin-top: 8%;
        margin-right: auto;
        margin-left: auto;
        background-color: rgb(28 63 203 / 64%);
        width: 170px;
        height: 170px;
        display: flow;
        border-radius: 50%;
        border: dotted #fefefe 3px;
        border-width: 1.5px;
        text-align: center;
      }

      .circle-content {
        padding-top: 38px;
        color: #fff;
      }

      @import url("https://fonts.googleapis.com/css2?family=Caveat&display=swap");

      .text {
        font-size: 4.5vw;
        line-height: 1em;
        font-family: "Cormorant Garamond", serif;
      }

      .small-text {
        font-size: 4vw;
      }

      @import url("https://fonts.googleapis.com/css2?family=Itim&display=swap");

      #date {
        line-height: 1.1em;
        font-size: 2.7em;
        font-family: "Itim", cursive;
      }

      .btn--orange,
      a.btn--orange {
        color: #fff;
        background-color: #eb6100;
      }

      .btn--orange:hover,
      a.btn--orange:hover {
        color: #fff;
        background: #f56500;
      }

      a.btn--radius {
        border-radius: 100vh;
      }

      #pic {
        display: block;
        width: 80%;
        margin: 10svh auto 0;
        padding: 10px 0;
        text-align: center;
        border: 0;
        box-shadow: none;
        color: #ffffff;
        font-weight: bold;
        font-size: 1rem;
        background-color: #f59827;
        cursor: pointer;
      }

      #submitLabel {
        display: block;
        width: 80%;
        margin: 16px auto 0;
        padding: 10px 0;
        text-align: center;
        border: 0;
        box-shadow: none;
        color: #ffffff;
        font-weight: bold;
        font-size: 1rem;
        background-color: #279cf5;
        cursor: pointer;
      }

      input {
        display: none;
      }

      content {
        position: absolute;
      }

      #backgroundimage {
        position: relative;
        opacity: 0.4;
        width: 60%;
        left: 50%;
        bottom: -5%;
        transform: translateX(-50%);
      }

      #picture {
        margin-top: 10%;
        margin-right: auto;
        margin-left: auto;
        width: 80%;
      }

      @keyframes sk-cubemove {
        25% {
          transform: translateX(42px) rotate(-90deg) scale(0.5);
        }

        50% {
          transform: translateX(42px) translateY(42px) rotate(-179deg);
        }

        50.1% {
          transform: translateX(42px) translateY(42px) rotate(-180deg);
        }

        75% {
          transform: translateX(0px) translateY(42px) rotate(-270deg) scale(0.5);
        }

        100% {
          transform: rotate(-360deg);
        }
      }

      .loader {
        position: absolute;
        top: 0;
        left: 0;
        z-index: 1;
        display: none;
        width: 100vw;
        height: 100vh;
        background-color: rgba(0, 0, 0, 0.5);
      }

      .loader__spinner {
        position: absolute;
        top: 50%;
        left: 50%;
        width: 57px;
        height: 57px;
        transform: translate(-50%, -50%);
      }

      .loader__spinner--cube1,
      .loader__spinner--cube2 {
        background-color: #ffffff;
        width: 15px;
        height: 15px;
        position: absolute;
        top: 0;
        left: 0;
        animation: sk-cubemove 2s infinite ease-in-out;
      }

      .loader__spinner--cube2 {
        animation-delay: -1s;
      }
    </style>
  </head>

  <body>
    <header></header>
    <main class="content">
      <div class="circle">
        <div class="circle-content">
          <div class="text">XXXX & YYYYYY</div>
          <div class="text">Wedding Party</div>
          <div id="date">11,4</div>
          <div class="text small-text">Saturday 2023</div>
        </div>
      </div>
      <div id="backgroundimage">
        <img src="./background-smile.png" />
      </div>
      <form
        method="post"
        id="image"
        action="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
      >
        <img id="picture" />
        <label for="upload" id="pic">写真を撮影する</label>
        <input
          id="upload"
          type="file"
          name="image"
          accept="image/jpg, image/jpeg, image/gif, image/png"
          onchange="Upload(this,'himage')"
        />
        <input type="hidden" id="himage" name="himage" />
        <div class="loader">
          <div class="loader__spinner">
            <div class="loader__spinner--cube1"></div>
            <div class="loader__spinner--cube2"></div>
          </div>
        </div>
      </form>
    </main>
    <footer>Designed by XXXX and YYYYYY. Invitation design © 2023.</footer>
  </body>
  <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
  <script>
    $("#picture").css("display", "none");

    // 1. ファイル選択後に呼ばれるイベント
    $("#upload").on("change", function (e) {
      // 2. 画像ファイルの読み込みクラス
      var reader = new FileReader();

      // 3. 準備が終わったら、id=sample1のsrc属性に選択した画像ファイルの情報を設定
      reader.onload = function (e) {
        $(".circle").css("display", "none");
        $("#picture").css("display", "block");
        $("#picture").attr("src", e.target.result);
        $("label").css("margin", "3svh auto 0");
        $("#backgroundimage").css("display", "none");
        let submitLabel = document.getElementById("submitLabel");
        if (submitLabel == null) {
          $("#picture").after(
            '<label for="submit" id="submitLabel">アップロード</label>'
          );
        }
        $("#submitLabel").after(
          '<input type="submit" id="submit" value="送信" onclick="sending()"/>'
        );
      };

      // 4. 読み込んだ画像ファイルをURLに変換
      reader.readAsDataURL(e.target.files[0]);
    });

    function Upload(input, id) {
      var file = input.files[0];
      var reader = new FileReader();
      reader.fileName = file.name;
      reader.readAsDataURL(file);
      reader.onload = function () {
        var image = document.getElementById(id);
        image.value = reader.result.split(",")[1];
      };
    }

    function sending() {
      const loader = document.getElementsByClassName("loader")[0];
      //ローダーを表示する
      loader.style.display = "block";
      sendout();
    }

    function sendout(msg) {
      $("form").on("submit", function (e) {
        // e.preventDefault();
        setTimeout(function () {
          // $("form").off("submit").submit();
          window.location.reload();
        }, 5000);
      });
    }
  </script>
</html>

これは、ユーザーが各自のスマートフォンから投稿する画面です。
中央の「写真を撮影する」ボタンを押すことで、スマートフォンのカメラアプリか写真フォルダに移行します。
写真を撮影 or 選択できたら、アップロードボタンが表示されます。

アップロードボタン押下後5秒後にページ更新を行うことで、ユーザの操作なしで再度写真撮影が可能となります。
ここがのちのち大きな穴となるとは...

smileys-input.html(iPhone 12 Pro).png

😑ちなみに

色々なところに「ミニオン」が登場する場合があるかもしれませんが、気にしないでください。
奥さんの希望であり、私の趣味ではありません

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2