addashboard-cli 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/submission.js +78 -1
- package/dist/commands/submission.js.map +1 -1
- package/dist/schemas/submit-plan.js +14 -0
- package/dist/schemas/submit-plan.js.map +1 -1
- package/package.json +3 -2
- package/skills/ad-ops/SKILL.md +7 -1
- package/skills/ad-ops/ad-planner.html +688 -53
- package/skills/ad-ops/scripts/adops.sh +28 -1
- package/skills/ad-ops//345/205/245/347/250/277/350/250/210/347/224/273/.ad-planner-plans +1 -0
|
@@ -190,6 +190,11 @@
|
|
|
190
190
|
.ddrow:hover{background:var(--accent-soft);}
|
|
191
191
|
.ddrow.cur{background:var(--accent-soft); box-shadow:inset 3px 0 0 var(--accent);}
|
|
192
192
|
.ddrow .ddno{font-family:var(--mono); color:var(--accent); font-weight:700; flex:0 0 auto; min-width:40px;}
|
|
193
|
+
/* 一覧の末尾に置く「+ 新規登録」。既存の番号行と地続きに見えないよう上の区切りを強くする */
|
|
194
|
+
.ddrow.ddnew{border-top:1px solid var(--line); border-bottom:none; background:var(--soft);
|
|
195
|
+
position:sticky; bottom:0; font-weight:600;}
|
|
196
|
+
.ddrow.ddnew:hover{background:var(--accent-soft);}
|
|
197
|
+
.ddrow.ddnew .ddno{font-size:15px;}
|
|
193
198
|
.ddrow .ddtx{color:var(--fg); overflow:hidden; text-overflow:ellipsis;}
|
|
194
199
|
/* サムネ (#157)。Finder のリスト表示に寄せて「小さい画像 + 名前」の1行にする。
|
|
195
200
|
画像が無いアカウントでも崩れないよう、読み込み失敗時は onerror で枠ごと畳む。 */
|
|
@@ -344,6 +349,24 @@
|
|
|
344
349
|
.mrow{display:grid; grid-template-columns:1fr 1fr; gap:12px;}
|
|
345
350
|
.modal .mfoot{display:flex; justify-content:flex-end; gap:8px; padding:14px 20px 18px; border-top:1px solid var(--line2); margin-top:8px; position:sticky; bottom:0; background:#fff;}
|
|
346
351
|
.hint{font-size:11.5px; color:var(--faint); margin-top:4px;}
|
|
352
|
+
/* 「+ 新規登録」の2ステップ (保存フォルダ → 素材) */
|
|
353
|
+
.nmstep{border:1px solid var(--line2); border-radius:10px; padding:12px 14px; margin:10px 0;}
|
|
354
|
+
.nmstep.cur{border-color:var(--accent);}
|
|
355
|
+
.nmstep.wait{opacity:.55;}
|
|
356
|
+
.nmstep .nmhead{display:flex; align-items:center; gap:8px; font-size:13px; font-weight:600;}
|
|
357
|
+
.nmstep .nmno{display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:50%; background:var(--soft); font-size:11.5px;}
|
|
358
|
+
.nmstep.done .nmno{background:var(--green-soft); color:var(--green);}
|
|
359
|
+
.nmstep .nmbody{display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin-top:8px; font-size:12.5px;}
|
|
360
|
+
.nmstep .nmbody .hint{flex-basis:100%; margin-top:0;}
|
|
361
|
+
.nmstep .nmpath{flex-basis:100%; display:flex; align-items:center; gap:8px; background:var(--soft); border-radius:8px; padding:8px 10px;}
|
|
362
|
+
.nmstep .nmpath .mono{flex:1; font-size:12px; word-break:break-all;}
|
|
363
|
+
.nmstep .nmhead .pill{font-weight:500; margin-left:4px;}
|
|
364
|
+
.nmitem{display:flex; gap:12px; align-items:flex-start; border:1px solid var(--line2); border-radius:10px; padding:10px; margin:8px 0;}
|
|
365
|
+
.nmitem img{width:120px; border:1px solid var(--line); border-radius:4px; flex:0 0 auto;}
|
|
366
|
+
.nmitem .nmmeta{flex:1; min-width:0;}
|
|
367
|
+
.nmitem .nmfile{font-size:12px; word-break:break-all; margin-bottom:6px;}
|
|
368
|
+
.nmitem .nmfields{display:flex; gap:10px; flex-wrap:wrap; font-size:12px;}
|
|
369
|
+
.nmitem .nmfields input{width:90px; font-size:13px; border:1px solid var(--line); border-radius:6px; padding:4px 6px;}
|
|
347
370
|
.toast{position:fixed; bottom:22px; left:50%; transform:translateX(-50%); background:#111418; color:#fff; font-size:12.5px; padding:9px 16px; border-radius:9px; z-index:100; box-shadow:0 8px 24px rgba(0,0,0,.25); opacity:0; transition:opacity .2s, transform .2s; max-width:min(720px,92vw); text-align:left; line-height:1.6;}
|
|
348
371
|
.toast.show{opacity:1; transform:translateX(-50%) translateY(-2px);}
|
|
349
372
|
/* 書き出しをブロックしたときは自動で消さない。原因を読み返せないと直しようがない */
|
|
@@ -424,6 +447,7 @@
|
|
|
424
447
|
<button data-yt="overview" class="active">概要</button>
|
|
425
448
|
<button data-yt="resp">レスポンシブ</button>
|
|
426
449
|
<button data-yt="banner">バナー</button>
|
|
450
|
+
<button data-yt="video">動画</button>
|
|
427
451
|
<button data-yt="param">パラメータ</button>
|
|
428
452
|
<button data-yt="guide">使い方</button>
|
|
429
453
|
</div>
|
|
@@ -487,7 +511,7 @@
|
|
|
487
511
|
<table class="g"><thead><tr><th>形式</th><th>入稿経路</th><th>利用</th><th>備考</th></tr></thead><tbody>
|
|
488
512
|
<tr><td><b>レスポンシブ(画像)・固定型</b></td><td><span class="pill green">API入稿 ★主用途</span></td><td>★★★</td><td>タイトル/説明文/画像を人が1通りに固定し、成果を自分で検証。<code>Card</code>(1200x628)/<code>SQ</code>(1080x1080) の2形式。API上は <code>RESPONSIVE_AD + mainMediaFormat:IMAGE</code>、画像は <code>asset.mediaAssets[].mediaId</code>(PR #82・実API疎通は検証中)</td></tr>
|
|
489
513
|
<tr><td>レスポンシブ(画像)・アセット型</td><td><span class="pill amber">非対象</span></td><td>—</td><td>複数タイトル/画像を登録しYahoo側が自動最適化。<b>1広告=単一組合せの本ツールでは非対象</b></td></tr>
|
|
490
|
-
<tr><td>レスポンシブ(動画)</td><td><span class="pill
|
|
514
|
+
<tr><td><b>レスポンシブ(動画)</b></td><td><span class="pill green">API入稿 対応済</span></td><td>★★☆</td><td>広告行の<b>形式列で「動画」</b>を選ぶ(広告タイプはレスポンシブのまま)。参照表は <code>refs.media[番号].video</code>/<code>.thumb</code>。<b>サムネイル画像が必須</b>で、<b>YDA は動画からサムネを自動生成しない</b>(本番実測)ため、クリエイティブ番号セルの ▾ → サイドバー最下部の<b>「+ 新規登録」</b>で動画を選び、0 秒フレームを抜いて入稿計画に載せる。<code>thumbnailFlg:'TRUE'</code> のサムネ画像は動画専用で、画像広告のメイン画像とは排他</td></tr>
|
|
491
515
|
<tr><td><b>バナー(静止画・固定サイズ)</b></td><td><span class="pill green">API入稿 対応済</span></td><td>★★★</td><td>広告行の<b>広告タイプ列で「バナー」</b>を選ぶ(画像は形式列=Card/SQ のまま、<b>レスポンシブと同じ画像プールを共用</b>)。API上は <code>BANNER_AD + mainMediaFormat:IMAGE</code>、画像は <b>operand 直下の <code>mediaId</code></b>。<b>タイトル/説明文/主体者/CTAはAPIフィールド自体が無い</b>(画像に焼き込み)。2026-08-19 に本番実機で add 成功・読み戻し検証済み</td></tr>
|
|
492
516
|
<tr><td>カルーセル / テキスト</td><td><span class="pill gray">未使用</span></td><td>—</td><td>運用では使わない</td></tr>
|
|
493
517
|
</tbody></table>
|
|
@@ -556,6 +580,21 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
|
556
580
|
<span class="c">// 読み戻すと ad.responsiveAd === null になる</span></div>
|
|
557
581
|
</div></details>
|
|
558
582
|
</div>
|
|
583
|
+
<div id="yt-video" style="display:none">
|
|
584
|
+
<details class="sec" open><summary><span class="num">1</span><span class="stitle">レスポンシブ動画 — 形式列で「動画」を選ぶ</span><span class="chev"></span></summary>
|
|
585
|
+
<div class="body">
|
|
586
|
+
<p class="lead">画像との違いは <b>素材の参照先</b> と <b>サムネイル</b> の 2 つだけ。広告タイプは「レスポンシブ」のまま、<b>形式列で「動画」</b>を選ぶ(API 上は <code>RESPONSIVE_AD + mainMediaFormat:VIDEO</code>)。タイトル・説明文・主体者・CTA は画像のレスポンシブと同じものを使う。</p>
|
|
587
|
+
<div class="callout"><b>サムネイル画像は必ず要る。</b> Yahoo は動画からサムネイルを自動生成しない(本番実測で確認済み)。サムネイルが無いと広告の作成そのものが失敗するので、プランナーが <b>動画の 0 秒フレームを抜いて</b>一緒に入稿する。</div>
|
|
588
|
+
<table class="g"><thead><tr><th>項目</th><th>内容</th></tr></thead><tbody>
|
|
589
|
+
<tr><td>参照表</td><td><code>refs.media[番号].video</code>(動画 mediaID)と <code>.thumb</code>(サムネ mediaID)。画像の <code>.card</code>/<code>.sq</code> とは別枠で、<b>同じ管理番号に画像と動画がぶら下がる</b></td></tr>
|
|
590
|
+
<tr><td>未登録素材</td><td>広告行の<b>「動画パス」「サムネパス」</b>に計画JSON基準の相対パスを入れる。本実行の直前に CLI が <code>/yda/upload-video</code> へ上げて mediaID に解決する(dry-run では上げない)</td></tr>
|
|
591
|
+
<tr><td><b>動画の取り込み</b></td><td>形式=動画 の行でクリエイティブ番号セルの ▾ を押し、サイドバー最下部の <b>「+ 新規登録」</b>を押す。モーダルで <b>① 保存フォルダ(<code>入稿計画/</code>)を選ぶ → ② 動画を選ぶ</b> の順に進めると、<b>動画パスと 0 秒サムネの両方が自動で入る</b>(素材は入稿計画フォルダの <code>素材/</code> に置かれる。保存フォルダは次回から自動でつながる)。<b>審査でサムネだけ落ちたときは、抽出位置(秒)を変えて取り直す</b></td></tr>
|
|
592
|
+
<tr><td>クリエイティブ名</td><td>形式記号は <code>v</code>(Card は <code>c</code>/SQ は <code>s</code>)。例 <code>957v-45L60-jv_Cac_148</code></td></tr>
|
|
593
|
+
<tr><td>できない組み合わせ</td><td><b>広告タイプ=バナー × 形式=動画</b>。入稿計画の書き出し前にエラーで止まる</td></tr>
|
|
594
|
+
</tbody></table>
|
|
595
|
+
<div class="callout">素材の置き場所は<b>入稿計画フォルダ配下の <code>素材/</code></b>。接続フォルダの外にある動画を選んだ場合はそこへコピーする(パスを自動で入れられるようにするため)。</div>
|
|
596
|
+
</div></details>
|
|
597
|
+
</div>
|
|
559
598
|
<div id="yt-param" style="display:none">
|
|
560
599
|
<details class="sec" open><summary><span class="num">1</span><span class="stitle">画面の入力項目(どこで何を設定するか)</span><span class="chev"></span></summary>
|
|
561
600
|
<div class="body">
|
|
@@ -617,7 +656,7 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
|
617
656
|
<li><b>プラン作成</b>:プランビューは「戦略メモ → 案件設定 → シート/ツリー切替」の順。シート選択時はさらに広告/広告グループ/キャンペーンのタブで編集する。AI が生成した state JSON は「JSON読込」で取り込む(旧v1形式も自動マイグレーション)。</li>
|
|
618
657
|
<li><b>確認</b>:バリデーションバッジ(タイトル35字超・参照未解決・paramCode未払出)が緑になるまで修正。バナーは広告行の「広告タイプ」列で「バナー」を選ぶ(タイトル/説明文は無効化される。画像はレスポンシブと同じ Card/SQ を使う)。</li>
|
|
619
658
|
<li><b>バージョン保存</b>:ヘッダーの「バージョン保存」。保存のたびに<b>常に新バージョン</b>(v1, v2…)として名称・メモ・作成者付きで保存される(上書きなし)。<b>履歴本体はブラウザ内に自動保存</b>されるため、フォルダ設定なしでも一覧・再読込できる。</li>
|
|
620
|
-
<li><b>保存フォルダ接続(初回に1回だけ)</b
|
|
659
|
+
<li><b>保存フォルダ接続(初回に1回だけ)</b>:素材の「+ 新規登録」のモーダル、または履歴ビューの「📁 保存フォルダに接続」でスキル同梱の <code>入稿計画/</code> フォルダを一度選ぶと(<code>入稿計画/</code> 以外のフォルダは接続できない)、<b>次回からは開くだけで自動接続</b>され、保存のたびにファイルにも書き出される。未接続時はダウンロード動作。</li>
|
|
621
660
|
<li><b>履歴の確認・再利用</b>:履歴ビューで一覧(Ver/日時/媒体/名称)を確認し、「読込」で過去バージョンを復元、「編集」で名称・メモ変更、「削除」で破棄。</li>
|
|
622
661
|
<li><b>入稿JSON書き出し</b>:ヘッダーの「入稿JSON書き出し」をワンクリック。アカウントID・目的・配信開始日は案件設定チップ、ターゲティングは広告グループ行の列から自動変換される(<code>*_入稿計画_*.json</code>)。</li>
|
|
623
662
|
<li><b>入稿(dry-run → 本実行)</b>:<code>webgram submit-plan --plan-file <入稿計画></code>。dry-run が全キャンペーンを一括検証 → OKなら <code>--no-dry-run --yes</code> で本実行(1キャンペーンずつ入稿・進捗書き戻しで中断後も再開可能)。実行後は管理画面の一覧でOK確認。</li>
|
|
@@ -802,6 +841,7 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
|
802
841
|
<div class="toolbar2">
|
|
803
842
|
<button class="act ghost" id="btnDupSel" title="選択した行を複製">⧉ 選択を複製</button>
|
|
804
843
|
<button class="act ghost" id="btnSeqParam" style="display:none" title="選択した列(paramCode / 画像番号 / タイトル番号 / 説明文番号)を先頭セル基点に連番採番">🔢 連番</button>
|
|
844
|
+
|
|
805
845
|
<span class="hinttxt">セル選択(クリック/Shift+クリック/ドラッグ)→ Cmd/Ctrl+C コピー・Cmd/Ctrl+V 貼付(1セルのコピーは選択範囲全体へ充填)/範囲選択のままプルダウン・入力欄を確定すると同じ列へ一括、ターゲティング列はサイドバーで一括ON/OFF</span>
|
|
806
846
|
</div>
|
|
807
847
|
<!-- シートタブと表示フィルタは同じ行に並べ、表の上辺に接合する (フォルダタブ風)。
|
|
@@ -878,7 +918,7 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
|
878
918
|
(cli/test/unit/plannerVersion.test.ts が package.json との一致を検証するので、
|
|
879
919
|
手で書き換えて放置するとテストが落ちる)。
|
|
880
920
|
利用者が「どの版のプランナーを開いているか」を言えないと、不具合報告が噛み合わない。 */
|
|
881
|
-
const APP_VERSION = '0.
|
|
921
|
+
const APP_VERSION = '0.13.0'; /* version:sync */
|
|
882
922
|
/* 案A: embed廃止。参照データ(refs)・初期プラン・共通設定は外部JSONから起動時に読み込む。
|
|
883
923
|
同梱デフォルト: アカウント/<媒体>/index.json の先頭アカウント (refs+principal+projectName+baseArticleUrl) と サンプルプラン.json (project+campaigns)。
|
|
884
924
|
固有名詞はハードコードせず、すべて外部JSON由来にする。 */
|
|
@@ -1140,6 +1180,11 @@ const EC = {
|
|
|
1140
1180
|
CAMPAIGN_NAME_DUPLICATE:'CAMPAIGN_NAME_DUPLICATE' /* 既存CPと同名。Google は同名を拒否する */,
|
|
1141
1181
|
CAMPAIGN_NAME_DUPLICATE_IN_PLAN:'CAMPAIGN_NAME_DUPLICATE_IN_PLAN' /* 同じ計画の中で新規CP名が重複 */,
|
|
1142
1182
|
LOCATION_EMPTY:'LOCATION_EMPTY' /* 地域未指定。google は全世界配信 (warn) / bytedance は入稿不可 (err) */,
|
|
1183
|
+
/* YDA レスポンシブ動画 固有 */
|
|
1184
|
+
VIDEO_PATH_MISSING:'VIDEO_PATH_MISSING' /* 動画 mediaID 未登録なのに動画パスも未入力 = 上げるものが無い */,
|
|
1185
|
+
THUMB_MISSING:'THUMB_MISSING' /* YDA は動画からサムネを自動生成しないので必ず要る */,
|
|
1186
|
+
VIDEO_PATH_IGNORED:'VIDEO_PATH_IGNORED' /* 動画 mediaID が解決済みなのにローカル動画パスも入力 (warn) */,
|
|
1187
|
+
BANNER_VIDEO_UNSUPPORTED:'BANNER_VIDEO_UNSUPPORTED' /* 広告タイプ=バナー × 形式=動画 は未対応 */,
|
|
1143
1188
|
/* ByteDance (TikTok) 固有 */
|
|
1144
1189
|
IDENTITY_MISSING:'IDENTITY_MISSING' /* 広告主として表示する TikTok アカウント未設定 = 広告が作れない */,
|
|
1145
1190
|
IDENTITY_BC_MISSING:'IDENTITY_BC_MISSING' /* identityType=BC_AUTH_TT なのに BC ID が無い (実運用の identity はほぼ BC_AUTH_TT) */,
|
|
@@ -1307,24 +1352,34 @@ const REF_TABLES = {
|
|
|
1307
1352
|
{label:'文字数', width:'70px', cls:(no,v)=>'reflen '+refLenCls(REF_TABLES.descriptions,v), cell:(no,v)=>glen(v)}
|
|
1308
1353
|
],
|
|
1309
1354
|
optionLabel:v=>String(v==null?'':v) },
|
|
1310
|
-
media:{ key:'media', label:'
|
|
1311
|
-
keyKind:'number', keyLabel:'
|
|
1312
|
-
addSub:'
|
|
1313
|
-
emptyMsg:'
|
|
1355
|
+
media:{ key:'media', label:'クリエイティブ', countLabel:'登録クリエイティブ', editable:true, required:true,
|
|
1356
|
+
keyKind:'number', keyLabel:'クリエイティブ番号', keyWidth:'110px',
|
|
1357
|
+
addSub:'クリエイティブ番号→Card/SQ/動画 の mediaID。新規作成した素材をメディアに登録',
|
|
1358
|
+
emptyMsg:'該当するクリエイティブがありません',
|
|
1314
1359
|
/* YDA のメディアライブラリはアカウントに1つだけ (バナー専用プールは存在しない)。
|
|
1315
1360
|
バナー広告もここの card / sq をそのまま使う。 */
|
|
1361
|
+
/* video / thumb は **レスポンシブ動画広告** 用 (動画 mediaId とサムネ画像 mediaId の組)。
|
|
1362
|
+
画像の card / sq と同じ番号を共有する = 1つの管理番号に画像・動画がぶら下がる。 */
|
|
1316
1363
|
valueFields:[{k:'card', label:'Card mediaID (1200)', editLabel:'Card mediaID'},
|
|
1317
|
-
{k:'sq', label:'SQ mediaID (1080)', editLabel:'SQ mediaID'}
|
|
1364
|
+
{k:'sq', label:'SQ mediaID (1080)', editLabel:'SQ mediaID'},
|
|
1365
|
+
{k:'video', label:'動画 mediaID', editLabel:'動画 mediaID'},
|
|
1366
|
+
{k:'thumb', label:'動画サムネ mediaID', editLabel:'サムネ mediaID'}],
|
|
1318
1367
|
keyCell:no=>'<td><span class="sw" style="display:inline-flex;width:26px;height:26px;vertical-align:middle">'+esc(no)+'</span> '+newBadge('media',no)+'</td>',
|
|
1319
1368
|
columns:[
|
|
1320
1369
|
{label:'Card mediaID (1200)', cls:'mono', cell:(no,v)=>esc(v&&v.card||'—')},
|
|
1321
1370
|
{label:'SQ mediaID (1080)', cls:'mono', cell:(no,v)=>esc(v&&v.sq||'—')},
|
|
1371
|
+
{label:'動画 mediaID', cls:'mono', cell:(no,v)=>esc(v&&v.video||'—')},
|
|
1372
|
+
{label:'サムネ mediaID', cls:'mono', cell:(no,v)=>esc(v&&v.thumb||'—')},
|
|
1373
|
+
/* 番号が動画として登録されているなら動画のファイル名を出す (画像の固定名を見せない) */
|
|
1322
1374
|
{label:'ファイル名', cls:'mono', style:'color:var(--faint);font-size:11px',
|
|
1323
|
-
cell:no=>
|
|
1375
|
+
cell:(no,v)=>(v&&v.video)
|
|
1376
|
+
? esc(no)+'.mp4'+(v.thumb?(' / '+esc(no)+'-thumb.jpg'):'')
|
|
1377
|
+
: esc(no)+'-1200.jpg / '+esc(no)+'-1080.jpg'}
|
|
1324
1378
|
],
|
|
1325
|
-
optionLabel:v=>[v&&v.card?'Card':'', v&&v.sq?'SQ':''].filter(Boolean).join('/'),
|
|
1379
|
+
optionLabel:v=>[v&&v.card?'Card':'', v&&v.sq?'SQ':'', v&&v.video?'動画':''].filter(Boolean).join('/'),
|
|
1326
1380
|
/* ▼ドロワー(一覧)の1行テキスト。媒体ごとの値の見せ方はここだけが持つ */
|
|
1327
|
-
drawerLabel:v=>'Card '+esc(v&&v.card||'—')+' / SQ '+esc(v&&v.sq||'—')
|
|
1381
|
+
drawerLabel:v=>'Card '+esc(v&&v.card||'—')+' / SQ '+esc(v&&v.sq||'—')
|
|
1382
|
+
+(v&&v.video?(' / 動画 '+esc(v.video)):'') },
|
|
1328
1383
|
adGroups:{ key:'adGroups', label:'広告グループ', countLabel:'広告グループ', editable:false, required:false,
|
|
1329
1384
|
keyKind:'string', keyLabel:'広告グループID', keyWidth:'110px',
|
|
1330
1385
|
emptyMsg:'既存の広告グループがありません(アカウントJSONの refs.adGroups)',
|
|
@@ -1434,9 +1489,14 @@ PLATFORMS.yda = {
|
|
|
1434
1489
|
(アップロードは base64 のみ、広告作成は mediaId のみ)。
|
|
1435
1490
|
→ **バナーもレスポンシブも同じ画像プール (refs.media[番号].card / .sq) を共用する**。
|
|
1436
1491
|
広告タイプ (RESPONSIVE_AD / BANNER_AD) は別の軸で、広告行の「広告タイプ」列 (ad.adKind) が持つ。 */
|
|
1492
|
+
/* 動画 (VIDEO) は画像と同じ「形式」軸に並ぶ第3の値。広告タイプ (レスポンシブ/バナー) とは直交し、
|
|
1493
|
+
レスポンシブ動画 = RESPONSIVE_AD + mainMediaFormat:VIDEO になる。
|
|
1494
|
+
動画は Card/SQ のようなサイズ区分を持たない (1本の動画 mediaId + サムネ画像 mediaId の組)。 */
|
|
1437
1495
|
mediaFormats:[
|
|
1438
1496
|
{id:'Card', crSuffix:'c', mediaKey:'card', apiMediaKind:'IMAGE', label:'Card mediaID (1200)'},
|
|
1439
|
-
{id:'SQ', crSuffix:'s', mediaKey:'sq', apiMediaKind:'IMAGE', label:'SQ mediaID (1080)'}
|
|
1497
|
+
{id:'SQ', crSuffix:'s', mediaKey:'sq', apiMediaKind:'IMAGE', label:'SQ mediaID (1080)'},
|
|
1498
|
+
{id:'動画', crSuffix:'v', mediaKey:'video', apiMediaKind:'VIDEO', label:'動画 mediaID',
|
|
1499
|
+
thumbKey:'thumb'}
|
|
1440
1500
|
],
|
|
1441
1501
|
/* 広告タイプがバナー (BANNER_AD) か。**サイズ(形式)ではなく ad.adKind で決まる**。
|
|
1442
1502
|
(タイトル/説明文セルの無効化・広告名・入稿ボディのテキスト系省略の判定はすべてここを見る。
|
|
@@ -1445,6 +1505,9 @@ PLATFORMS.yda = {
|
|
|
1445
1505
|
adKind:' バナー ' のような値で adType だけ BANNER_AD になり、
|
|
1446
1506
|
テキスト系フィールドが入稿計画JSONに混入する。 */
|
|
1447
1507
|
isBanner(ad){ return PLATFORMS.yda.mappers.adType((ad&&ad.adKind), PLATFORMS.yda)==='BANNER_AD'; },
|
|
1508
|
+
/* 形式=動画 か。**広告タイプ (adKind) ではなく形式 (format) の軸**。
|
|
1509
|
+
列の有効/無効・参照するメディアキー・入稿ボディの mediaType はすべてここを見る。 */
|
|
1510
|
+
isVideo(ad){ return PLATFORMS.yda.fmtOf(ad).apiMediaKind==='VIDEO'; },
|
|
1448
1511
|
fmtOf(ad){ const F=PLATFORMS.yda.mediaFormats; return F.find(x=>x.id===(ad&&ad.format)) || F[0]; },
|
|
1449
1512
|
|
|
1450
1513
|
/* ---- 参照テーブル (S6 の REF_TABLES) ---- */
|
|
@@ -1557,7 +1620,8 @@ PLATFORMS.yda = {
|
|
|
1557
1620
|
},
|
|
1558
1621
|
/* C6: adName は空 = 自動生成 (naming.adName)。C1: imagePath は空 = 従来挙動 */
|
|
1559
1622
|
/* adKind = 広告タイプ軸 (レスポンシブ / バナー)。format はサイズ軸 (Card / SQ) で直交する */
|
|
1560
|
-
ad:{format:'Card', adKind:'レスポンシブ', imageNo:'', titleNo:'', descNo:'', imagePath:'',
|
|
1623
|
+
ad:{format:'Card', adKind:'レスポンシブ', imageNo:'', titleNo:'', descNo:'', imagePath:'',
|
|
1624
|
+
videoPath:'', thumbnailPath:'', adName:'', desc2:''},
|
|
1561
1625
|
/* S14: freq* は「案件設定チップの既定を新規CP作成時にコピーする」ので factories.newCampaign 側で入れる */
|
|
1562
1626
|
campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false, urlNumber:'',
|
|
1563
1627
|
freqMode:'default', freqOn:false, freqLevel:'AD_GROUP', freqUnit:'WEEK', freqCount:3}
|
|
@@ -1621,7 +1685,7 @@ PLATFORMS.yda = {
|
|
|
1621
1685
|
}
|
|
1622
1686
|
},
|
|
1623
1687
|
/* 「クリエイティブをクリア」対象フィールド */
|
|
1624
|
-
creativeFields:['imageNo','titleNo','descNo','imagePath'],
|
|
1688
|
+
creativeFields:['imageNo','titleNo','descNo','imagePath','videoPath','thumbnailPath'],
|
|
1625
1689
|
/* AG の 未反映列 (入稿JSONに載らない = tgDropped 警告の対象)。
|
|
1626
1690
|
C7: オーディエンス/配置は実ID入稿に対応したので **未反映列から外した** */
|
|
1627
1691
|
notSubmittedFields:['tg_region','tg_searchKw'],
|
|
@@ -1635,7 +1699,8 @@ PLATFORMS.yda = {
|
|
|
1635
1699
|
+ '<span class="chip">'+esc(c.biddingStrategy||p.biddingStrategyDefault)+' <b>'+esc((c.tcpa??p.tcpaDefault))+'</b></span>';
|
|
1636
1700
|
},
|
|
1637
1701
|
groupBadges(g){ return '<span class="pill mono">【'+esc(g.paramCode||'—')+'】</span>'; },
|
|
1638
|
-
|
|
1702
|
+
/* 形式記号は mediaFormats.crSuffix が正 (c / s / v)。format の1文字目を取ると「動画」が「動」になる */
|
|
1703
|
+
adBadge(a, r){ return '<span class="cbadge">'+esc(a.imageNo||'—')+esc(PLATFORMS.yda.fmtOf(a).crSuffix)+'</span>'; },
|
|
1639
1704
|
adSub(a, r){ return r.isBanner ? 'バナー' : esc(a.titleNo)+'L'+esc(a.descNo); },
|
|
1640
1705
|
/* バナーにはタイトルが存在しない。シート側は「—」で無効化しているのに
|
|
1641
1706
|
ツリーが「(未入力)」と出すと「埋めるべき欄」に見えるので別表記にする。 */
|
|
@@ -1652,11 +1717,20 @@ PLATFORMS.yda = {
|
|
|
1652
1717
|
const autoAdName=P.naming.adName(ad, group, crName, p);
|
|
1653
1718
|
const adName=String(ad.adName||'').trim() || autoAdName;
|
|
1654
1719
|
const me = refs.media[String(ad.imageNo)];
|
|
1655
|
-
|
|
1720
|
+
/* 参照表の編集モーダル (refEdit) は空欄を **空文字で保存する** ので、'' も未登録として扱う。
|
|
1721
|
+
undefined だけを見ていると ID が空のまま「登録済み」と判定され、警告も出ずに
|
|
1722
|
+
mediaIds:[''] の計画が書き出されて dry-run も通る。YDA には自動ロールバックが無いので、
|
|
1723
|
+
本実行では CP と広告グループだけが残る。Meta の resolve と同じ判定に揃えている。 */
|
|
1724
|
+
const mediaId = (me ? me[fmt.mediaKey] : undefined) || undefined;
|
|
1656
1725
|
/* バナー広告 (BANNER_AD) はタイトル/説明文のAPI項目が存在しない (画像に焼き込む運用) ので、
|
|
1657
1726
|
参照も検証もしない。セル自体も列記述子の col.disabled で無効化している。
|
|
1658
1727
|
判定は「広告タイプ」列 (ad.adKind) — 画像サイズ (Card/SQ) とは独立した軸。 */
|
|
1659
1728
|
const isBanner = P.isBanner(ad);
|
|
1729
|
+
/* 形式=動画 のときは動画 mediaId (me.video) とサムネ mediaId (me.thumb) の組で解決する。
|
|
1730
|
+
サムネは YDA が自動生成しないので、mediaId かローカルパスのどちらかが必ず要る。 */
|
|
1731
|
+
const isVideo = P.isVideo(ad);
|
|
1732
|
+
// mediaId と同じ理由で '' も未登録として扱う(サムネ未設定のまま入稿されるのを防ぐ)
|
|
1733
|
+
const thumbId = ((isVideo && me) ? me[fmt.thumbKey] : undefined) || undefined;
|
|
1660
1734
|
const titleText = isBanner ? undefined : refs.titles[ad.titleNo];
|
|
1661
1735
|
const descText = isBanner ? undefined : (refs.descriptions||{})[ad.descNo]; // A-5: 説明文テーブルのみ参照 (titlesへのフォールバックなし)
|
|
1662
1736
|
const finalUrl = P.naming.finalUrl(ad, group, adName, p);
|
|
@@ -1681,11 +1755,27 @@ PLATFORMS.yda = {
|
|
|
1681
1755
|
// B11: paramCode空はエラー(export対象外・URL生成に必須)。F-6: refs.groups 払出判定は廃止。
|
|
1682
1756
|
/* C8: URLパラメータ OFF の案件では paramCode 自体が不要 (列も隠す) のでエラーにしない */
|
|
1683
1757
|
if(p.urlParams!==false && String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
|
|
1684
|
-
if(pendingImage) pushWarn('画像未登録(要アップロード)', EC.MEDIA_MISSING); // errではなくwarn: 新規作成対象として保持
|
|
1758
|
+
if(pendingImage) pushWarn(isVideo?'動画未登録(要アップロード)':'画像未登録(要アップロード)', EC.MEDIA_MISSING); // errではなくwarn: 新規作成対象として保持
|
|
1759
|
+
/* 動画 (レスポンシブ動画) の検証。画像とは要求が違う:
|
|
1760
|
+
・動画 mediaId も動画パスも無ければ **上げるものが無い** → err (TikTok と同じ。画像の warn 止まりとは違う)
|
|
1761
|
+
・**サムネは必須**。YDA は動画からサムネを自動生成しないので (本番実測)、
|
|
1762
|
+
mediaId かローカルパスのどちらかが無いと広告作成が R0001 で落ちる
|
|
1763
|
+
・バナー × 動画 は backend の zod が弾く組み合わせ。dry-run より手前で気づけるよう err にする */
|
|
1764
|
+
const videoPath=String(ad.videoPath||'').trim();
|
|
1765
|
+
const thumbnailPath=String(ad.thumbnailPath||'').trim();
|
|
1766
|
+
if(isVideo){
|
|
1767
|
+
if(isBanner) pushErr('バナー×動画は未対応', EC.BANNER_VIDEO_UNSUPPORTED);
|
|
1768
|
+
if(imgNoStr!=='' && pendingImage && videoPath==='') pushErr('動画パス未入力(動画mediaID未登録)', EC.VIDEO_PATH_MISSING);
|
|
1769
|
+
if(!pendingImage && videoPath!=='') pushWarn('動画パス無視(動画mediaIDが解決済み)', EC.VIDEO_PATH_IGNORED);
|
|
1770
|
+
/* サムネのローカルパスは **動画を上げるときにしか送れない** (CLI は upload-video の multipart に
|
|
1771
|
+
添える形でしか受け取らない)。動画 mediaID が解決済みの行では thumb mediaID が無いと入稿できない。 */
|
|
1772
|
+
if(imgNoStr!=='' && thumbId===undefined && (thumbnailPath==='' || !pendingImage))
|
|
1773
|
+
pushErr(pendingImage?'サムネ未設定(動画広告に必須)':'サムネ mediaID 未登録(動画が登録済みの行ではパスを使えません)', EC.THUMB_MISSING);
|
|
1774
|
+
}
|
|
1685
1775
|
// C6-1: 未登録画像なのにローカル画像パス未入力 = 入稿時にアップロード元が無い。
|
|
1686
1776
|
// ただし err にはしない (CLI/agent が後から imagePaths を補完する運用と共存する)
|
|
1687
|
-
const imagePaths=String(ad.imagePath||'').split(',').map(s=>s.trim()).filter(Boolean);
|
|
1688
|
-
if(pendingImage && imagePaths.length===0) pushWarn('画像パス未入力', EC.IMAGE_PATH_MISSING);
|
|
1777
|
+
const imagePaths=isVideo?[]:String(ad.imagePath||'').split(',').map(s=>s.trim()).filter(Boolean);
|
|
1778
|
+
if(!isVideo && pendingImage && imagePaths.length===0) pushWarn('画像パス未入力', EC.IMAGE_PATH_MISSING);
|
|
1689
1779
|
/* 逆に mediaID が解決済みの広告に画像パスを足しても、その画像は広告には使われない
|
|
1690
1780
|
(CLI はアップロードした mediaId を末尾に **追記** し、backend は mediaIds[0] しか見ない)。
|
|
1691
1781
|
黙って古い画像で入稿されるのが最悪なので、書き出しでは落とし、ここで警告する。
|
|
@@ -1694,7 +1784,8 @@ PLATFORMS.yda = {
|
|
|
1694
1784
|
// F-6: ID解決は adGroupId ベース。campaignId は idsCell表示用に持たない(親CP参照は呼び出し側)
|
|
1695
1785
|
const aid=String(group.adGroupId||'').trim();
|
|
1696
1786
|
return {crName, adName, autoAdName, imagePaths, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind,
|
|
1697
|
-
adType:P.mappers.adType(ad.adKind, P), isBanner, imageNo:imgNoStr,
|
|
1787
|
+
adType:P.mappers.adType(ad.adKind, P), isBanner, isVideo, imageNo:imgNoStr,
|
|
1788
|
+
videoPath, thumbnailPath, thumbId:thumbId||'',
|
|
1698
1789
|
mediaId:mediaId||'', pendingImage, titleText:titleText===undefined?'':titleText,
|
|
1699
1790
|
descText:descText===undefined?'':descText, finalUrl, errs, warns, errCodes, warnCodes, resolvedGroup:!!aid};
|
|
1700
1791
|
},
|
|
@@ -1785,7 +1876,9 @@ PLATFORMS.yda = {
|
|
|
1785
1876
|
const ad={
|
|
1786
1877
|
adName:r.adName,
|
|
1787
1878
|
status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
|
|
1788
|
-
|
|
1879
|
+
/* 形式 (Card/SQ/動画) 由来。画像広告は IMAGE (バナーもメイン形式は IMAGE)、
|
|
1880
|
+
レスポンシブ動画は VIDEO (backend ydaAdSchema mediaType)。 */
|
|
1881
|
+
mediaType:r.mediaKind,
|
|
1789
1882
|
adType:r.adType,
|
|
1790
1883
|
finalUrl:r.finalUrl // 実データ準拠 (?ad=paramCode&creative=...)
|
|
1791
1884
|
};
|
|
@@ -1798,6 +1891,17 @@ PLATFORMS.yda = {
|
|
|
1798
1891
|
ad.principal=p.principal||''; // E-1: 主体者はアカウントJSON由来 (st.project.principal)
|
|
1799
1892
|
ad.buttonText=P.mappers.cta(p.cta, P); // CTA(案件設定)→ YDA buttonText enum。入稿ADD必須
|
|
1800
1893
|
}
|
|
1894
|
+
/* レスポンシブ動画 (mediaType:VIDEO)。画像の mediaIds ではなく videoMediaId + thumbnailMediaId を送る。
|
|
1895
|
+
未登録なら videoPath / thumbnailPath を残し、CLI が本実行の直前に /yda/upload-video へ上げて
|
|
1896
|
+
両IDに解決する (dry-run では上げない)。**IDとパスを同時に載せない** — 載せると CLI が
|
|
1897
|
+
余計にアップロードして、広告には古いIDが使われる (画像側で踏んだのと同じ罠)。 */
|
|
1898
|
+
if(r.isVideo){
|
|
1899
|
+
if(r.pendingImage){ ad.videoPath=r.videoPath; ad.imageNo=r.imageNo; ad.pendingImageNo=r.imageNo; }
|
|
1900
|
+
else ad.videoMediaId=String(r.mediaId);
|
|
1901
|
+
if(r.thumbId) ad.thumbnailMediaId=String(r.thumbId);
|
|
1902
|
+
else if(r.thumbnailPath) ad.thumbnailPath=r.thumbnailPath;
|
|
1903
|
+
return ad;
|
|
1904
|
+
}
|
|
1801
1905
|
if(r.pendingImage){
|
|
1802
1906
|
// 未登録画像: mediaId未確定。管理番号を残し CLI が先にアップロードして解決する
|
|
1803
1907
|
// C6-1: 画像パス列 (カンマ区切り) を imagePaths に展開する。空なら [] のまま (agent/CLI が補完)
|
|
@@ -1907,7 +2011,8 @@ PLATFORMS.yda = {
|
|
|
1907
2011
|
}
|
|
1908
2012
|
},
|
|
1909
2013
|
docTabs:[{id:'overview',label:'概要'},{id:'resp',label:'レスポンシブ'},
|
|
1910
|
-
{id:'banner',label:'バナー'},{id:'
|
|
2014
|
+
{id:'banner',label:'バナー'},{id:'video',label:'動画'},
|
|
2015
|
+
{id:'param',label:'パラメータ'},{id:'guide',label:'運用手順'}]
|
|
1911
2016
|
};
|
|
1912
2017
|
|
|
1913
2018
|
/* 宣言に載せない旧テーブル。resolveGroupIds/maxParamNum が参照中のため S11 の撤去まで温存する。 */
|
|
@@ -2085,6 +2190,7 @@ function initialState(){
|
|
|
2085
2190
|
campaigns.forEach(c=>{ c.uid=uid('c'); (c.adGroups||[]).forEach(g=>{ g.uid=uid('g'); (g.ads||[]).forEach(a=>{ a.uid=uid('a'); }); }); });
|
|
2086
2191
|
migrateArticleSlug(campaigns); // C8: 旧 articleSlug / articleUrl を除去
|
|
2087
2192
|
migratePlatformValues(campaigns); // 媒体側の旧値を現行の語彙へ
|
|
2193
|
+
project.localMedia=samplePlanLocalMedia();
|
|
2088
2194
|
}
|
|
2089
2195
|
}
|
|
2090
2196
|
return {schemaVersion:SCHEMA_VERSION, meta:defaultMeta(), project, refs, campaigns, ui:{expanded:{}}};
|
|
@@ -2603,7 +2709,9 @@ function refEdit(tableKey,no){
|
|
|
2603
2709
|
value:single?cur:((cur||{})[fd.k]||'')})),
|
|
2604
2710
|
onOk:v=>{ refCommit(()=>{
|
|
2605
2711
|
if(single){ store[no]=v[T.valueFields[0].k]; return; }
|
|
2606
|
-
|
|
2712
|
+
/* 空欄は **キーごと落とす**。空文字を入れると resolve が「登録済み」と誤判定し、
|
|
2713
|
+
ID が空のまま入稿されて広告作成で落ちる (作りかけの CP が残る)。 */
|
|
2714
|
+
const e={}; T.valueFields.forEach(fd=>{ const sv=String(v[fd.k]).trim(); if(sv!=='') e[fd.k]=sv; }); store[no]=e;
|
|
2607
2715
|
}, '更新しました'); }});
|
|
2608
2716
|
}
|
|
2609
2717
|
function refDel(tableKey,no){
|
|
@@ -2729,10 +2837,11 @@ const COLS = {
|
|
|
2729
2837
|
ads:[
|
|
2730
2838
|
/* 形式 = **画像のサイズ軸** (Card=1200x628 / SQ=1080x1080)。参照表 refs.media[番号].card / .sq を引く。
|
|
2731
2839
|
広告タイプ (レスポンシブ/バナー) とは直交する軸で、バナーも同じ画像プールを使う。 */
|
|
2732
|
-
{field:'format', label:'形式', kind:'optpick', options:['Card','SQ'], width:'72px',
|
|
2733
|
-
title:'
|
|
2734
|
-
coerce:v=>{ const s=String(v).trim().toUpperCase();
|
|
2735
|
-
if(s==='
|
|
2840
|
+
{field:'format', label:'形式', kind:'optpick', options:['Card','SQ','動画'], width:'72px',
|
|
2841
|
+
title:'素材の種類。Card / SQ は画像のサイズ(バナーも同じ画像プールを使います)、動画はレスポンシブ動画広告',
|
|
2842
|
+
coerce:v=>{ const s=String(v).trim(), u=s.toUpperCase();
|
|
2843
|
+
if(s==='動画'||u==='VIDEO') return '動画';
|
|
2844
|
+
if(u==='SQ') return 'SQ';
|
|
2736
2845
|
return 'Card'; }},
|
|
2737
2846
|
/* 広告タイプ = **RESPONSIVE_AD / BANNER_AD の軸**。行単位で切り替えるので同一キャンペーン内に混在できる。
|
|
2738
2847
|
バナー行はタイトル/説明文のセルが col.disabled で無効化される (PLATFORMS.yda.isBanner)。 */
|
|
@@ -2741,9 +2850,9 @@ const COLS = {
|
|
|
2741
2850
|
get:row=>row.adKind||'レスポンシブ',
|
|
2742
2851
|
coerce:v=>{ const s=String(v).trim();
|
|
2743
2852
|
return (s==='バナー'||s.toUpperCase()==='BANNER'||s.toUpperCase()==='BANNER_AD')?'バナー':'レスポンシブ'; }},
|
|
2744
|
-
{field:'imageNo', label:'
|
|
2853
|
+
{field:'imageNo', label:'クリエイティブ番号', kind:'refcombo', refTable:'media', drawerType:'media', mediaAxis:'format',
|
|
2745
2854
|
width:'6ch', sequenceable:true, errCodes:[EC.IMAGE_UNRESOLVED],
|
|
2746
|
-
ghostKey:'media', ddTitle:'
|
|
2855
|
+
ghostKey:'media', ddTitle:'登録クリエイティブから選択', ghostTitle:r=>'メディアID '+r.mediaId,
|
|
2747
2856
|
ghost:(r,a)=>{ if(!a.imageNo) return '<span class="idpill" style="color:var(--faint)">—</span>';
|
|
2748
2857
|
if(r.mediaId!=='') return '<span class="idpill">ID '+esc(r.mediaId)+'</span>';
|
|
2749
2858
|
if(r.pendingImage) return '<span class="idpill warn" title="アカウント未登録。入稿前にアップロードして新規登録">🆕未登録</span>';
|
|
@@ -2784,7 +2893,25 @@ const COLS = {
|
|
|
2784
2893
|
disabledTitle:'バナー広告(BANNER_AD)には説明文のAPI項目がありません(画像に焼き込み)'}),
|
|
2785
2894
|
/* C6-1: 量産時のローカル画像パス担体 (カンマ区切りで複数可)。少数入稿は Skill の画像入稿フローが主導線 */
|
|
2786
2895
|
{field:'imagePath', label:'画像パス', kind:'mono', width:'150px',
|
|
2896
|
+
disabled:it=>it.kind==='a' && PLATFORMS.yda.isVideo(it.a),
|
|
2897
|
+
disabledTitle:'形式=動画の行では画像パスを使いません(動画パス / サムネパスを使います)',
|
|
2787
2898
|
title:'ローカル画像の絶対 / ~/… / 計画JSON基準の相対パス。カンマ区切りで複数可。※画像番号が参照表で解決できる行では入稿JSONに載りません(差し替えたいときは画像番号を空に)'},
|
|
2899
|
+
/* 形式=動画 のローカル素材パス。**クリエイティブ番号の右サイドバー「+ 新規登録」から選ぶと、
|
|
2900
|
+
動画パスと 0 秒サムネ (自動抽出→接続フォルダへ書き出し) の両方が自動で入る**。手入力も可。
|
|
2901
|
+
本実行の直前に CLI が /yda/upload-video へ上げて videoMediaId / thumbnailMediaId に解決する
|
|
2902
|
+
(dry-run では上げない)。動画 mediaID が参照表で解決済みの行では入稿JSONに載らない。 */
|
|
2903
|
+
{field:'videoPath', label:'動画パス', kind:'mono', width:'150px',
|
|
2904
|
+
disabled:it=>it.kind==='a' && !PLATFORMS.yda.isVideo(it.a),
|
|
2905
|
+
disabledTitle:'形式が「動画」の行だけで使います',
|
|
2906
|
+
errCodes:[EC.VIDEO_PATH_MISSING],
|
|
2907
|
+
title:'ローカル動画の絶対 / ~/… / 計画JSON基準の相対パス。※動画 mediaID が参照表で解決できる行では入稿JSONに載りません'},
|
|
2908
|
+
/* 動画広告のサムネイル画像。YDA は **動画からサムネを自動生成しない** (本番実測) ので、
|
|
2909
|
+
0 秒フレームを抽出した画像を必ず一緒に入稿する必要がある。 */
|
|
2910
|
+
{field:'thumbnailPath', label:'サムネパス', kind:'mono', width:'150px',
|
|
2911
|
+
disabled:it=>it.kind==='a' && !PLATFORMS.yda.isVideo(it.a),
|
|
2912
|
+
disabledTitle:'形式が「動画」の行だけで使います',
|
|
2913
|
+
errCodes:[EC.THUMB_MISSING],
|
|
2914
|
+
title:'動画サムネイル画像のパス。クリエイティブ番号のサイドバー「+ 新規登録」で動画を選ぶと、0 秒フレームを自動抽出して埋まります(審査で落ちたときだけ差し替える)'},
|
|
2788
2915
|
/* C6: 広告名は自由入力 (空 = 自動生成)。placeholder に自動生成値を出す */
|
|
2789
2916
|
{field:'adName', label:'広告名', kind:'mono', width:'170px',
|
|
2790
2917
|
title:'空欄なら自動生成(レスポンシブ: {画像番号}{c|s}-{タイトル番号}L{説明文番号}-{paramCode} / バナー: {画像番号}{c|s}-{paramCode})。手入力すると最終リンク先URLの creative= も変わります',
|
|
@@ -5161,7 +5288,9 @@ function applySequentialParamCodes(){
|
|
|
5161
5288
|
const m=String(rows[0].a[sc.field]||'').trim().match(/^(.*?)(\d+)\s*$/);
|
|
5162
5289
|
if(!m){ toast('基点の'+lbl+'を先頭セルに入力してください(例 954)'); return; }
|
|
5163
5290
|
const prefix=m[1], startNum=parseInt(m[2],10), width=m[2].length;
|
|
5164
|
-
mutate(()=>{ rows.forEach((it,i)=>{ if(i===0) return; it.a[sc.field]=prefix+String(startNum+i).padStart(width,'0');
|
|
5291
|
+
mutate(()=>{ rows.forEach((it,i)=>{ if(i===0) return; it.a[sc.field]=prefix+String(startNum+i).padStart(width,'0');
|
|
5292
|
+
/* この計画で新規登録した番号になったら、その素材のパスも入れる */
|
|
5293
|
+
if(sc.field==='imageNo') applyLocalMediaPaths(it, it.a[sc.field]); }); });
|
|
5165
5294
|
toast((rows.length-1)+'件の'+lbl+'に連番を採番しました('+prefix+(startNum+1)+'〜)');
|
|
5166
5295
|
}
|
|
5167
5296
|
}
|
|
@@ -5451,18 +5580,101 @@ function renderDrawer(){
|
|
|
5451
5580
|
const rkey=(dcol&&dcol.refTable) || (drawerTarget.type==='titles'?'titles':'media');
|
|
5452
5581
|
const T=refTableDef(rkey)||{}, store=st.refs[rkey]||{};
|
|
5453
5582
|
const single=!!(T.valueFields&&T.valueFields.length===1);
|
|
5454
|
-
|
|
5583
|
+
/* 素材の一覧は **その行の形式に合うものだけ** 出す。動画行に画像しか持たない番号を
|
|
5584
|
+
並べても選べないため。形式ごとの参照キー (YDA=card/sq/video / Meta・Google=image/video
|
|
5585
|
+
/ TikTok=video) は媒体定義の mediaFormats が持つので、コアは mediaKey だけを見る。 */
|
|
5586
|
+
const isMedia = rkey==='media';
|
|
5587
|
+
const fmt = (isMedia && fa) ? pf().fmtOf(fa.a) : null;
|
|
5588
|
+
const hasFmt = v => !fmt || !fmt.mediaKey || String((v||{})[fmt.mediaKey]||'').trim()!=='';
|
|
5589
|
+
titleEl.textContent = single ? ((T.label||'')+'一覧(文言ライブラリ)')
|
|
5590
|
+
: ((T.countLabel||T.label||'')+' 一覧'+(fmt?('('+fmt.id+')'):''));
|
|
5455
5591
|
let html='';
|
|
5456
5592
|
refSortedKeys({keyKind:'number'},store).forEach(no=>{
|
|
5457
5593
|
const v=store[no];
|
|
5458
5594
|
if(T.dropEmpty && String(v==null?'':v).trim()==='') return;
|
|
5595
|
+
if(isMedia && !hasFmt(v)) return;
|
|
5459
5596
|
if(q && !refSearchStrings(T,no,v).some(s=>s.includes(q))) return;
|
|
5460
5597
|
const txt = T.drawerLabel ? T.drawerLabel(v) : esc(String(v==null?'':v));
|
|
5461
5598
|
html+='<div class="ddrow'+(String(no)===curNo?' cur':'')+'" data-no="'+esc(no)+'">'
|
|
5462
5599
|
+(single?'':thumbImgHtml(no))+'<span class="ddno">'+esc(no)+'</span>'
|
|
5463
5600
|
+(single?'':newBadge(rkey,no))+'<span class="ddtx">'+txt+'</span></div>';
|
|
5464
5601
|
});
|
|
5465
|
-
|
|
5602
|
+
/* この計画で新しく登録した素材。**まだ媒体に上がっていないので mediaID が無く、refs には入れない**
|
|
5603
|
+
(空の ID を入れると「登録済み」と誤判定され、CLI のアップロードが走らない)。
|
|
5604
|
+
一覧には出して、2行目以降でも同じ番号を選べるようにする。 */
|
|
5605
|
+
if(isMedia) pendingMediaNos().forEach(no=>{
|
|
5606
|
+
if(q && !String(no).includes(q)) return;
|
|
5607
|
+
const lm=((st.project&&st.project.localMedia)||{})[no];
|
|
5608
|
+
/* 種類 (動画 / 画像) が行と違う素材は出さない */
|
|
5609
|
+
if(lm && fmt && ((fmt.apiMediaKind==='VIDEO') !== !!lm.video)) return;
|
|
5610
|
+
const thumb=lm ? (lm.thumb||lm.image) : '';
|
|
5611
|
+
html+='<div class="ddrow'+(String(no)===curNo?' cur':'')+'" data-no="'+esc(no)+'">'
|
|
5612
|
+
+(thumb?'<img class="ddthumb" data-localthumb="'+esc(thumb)+'" alt="">':'')
|
|
5613
|
+
+'<span class="ddno">'+esc(no)+'</span>'
|
|
5614
|
+
+'<span class="idpill warn">🆕未登録</span>'
|
|
5615
|
+
+'<span class="ddtx">'+(lm&&lm.file?esc(lm.file)+' ':'')+'この計画で追加(入稿時にアップロードします)</span></div>';
|
|
5616
|
+
});
|
|
5617
|
+
listEl.innerHTML=seqRowHtml()+(html||'<div class="empty" style="padding:24px">該当なし</div>')
|
|
5618
|
+
+ ((isMedia && newMediaFields(fmt)) ? newMediaRowHtml() : '');
|
|
5619
|
+
hydrateLocalThumbs(listEl);
|
|
5620
|
+
}
|
|
5621
|
+
|
|
5622
|
+
/** サイドバー末尾の1行。**素材の置き場所が無いと登録できない**ので、
|
|
5623
|
+
未接続のときは登録ではなく接続を促す(ヘッダーに接続ボタンは無く、履歴ビューの奥にある)。 */
|
|
5624
|
+
/** 複数行を選んでいるとき、サイドバーの先頭に「連番」を出す (ツールバーの 🔢 連番 と同じ処理) */
|
|
5625
|
+
function seqRowHtml(){
|
|
5626
|
+
const btn=document.getElementById('btnSeqParam');
|
|
5627
|
+
if(!btn || btn.style.display==='none') return '';
|
|
5628
|
+
const sc=seqColInfo(); if(!sc) return '';
|
|
5629
|
+
return '<div class="ddrow ddseq" data-seq="1"><span class="ddno">🔢</span>'
|
|
5630
|
+
+'<span class="ddtx">選択した行の'+esc(sc.label)+'を連番にする<span class="sm" style="color:var(--faint)"> 先頭のセルの番号から</span></span></div>';
|
|
5631
|
+
}
|
|
5632
|
+
function newMediaRowHtml(){
|
|
5633
|
+
if(!fsSupported)
|
|
5634
|
+
return '<div class="ddrow ddnew" style="cursor:default;color:var(--faint)">'
|
|
5635
|
+
+ '<span class="ddno">—</span><span class="ddtx">このブラウザでは素材を登録できません'
|
|
5636
|
+
+ '(Chrome 系で開いてください)</span></div>';
|
|
5637
|
+
/* 保存フォルダの接続は「+ 新規登録」のモーダル (openNewMediaWizard) の中で案内する */
|
|
5638
|
+
return '<div class="ddrow ddnew" data-newmedia="1"><span class="ddno">+</span>'
|
|
5639
|
+
+ '<span class="ddtx">新規登録(手元のファイルから)'
|
|
5640
|
+
+ (dirHandle ? '<span class="sm" style="color:var(--faint)"> 保存先: '+esc(dirHandle.name)+'/</span>' : '')
|
|
5641
|
+
+ '</span></div>';
|
|
5642
|
+
}
|
|
5643
|
+
|
|
5644
|
+
/** 新規登録で使う**パス列**。素材を置いてもパスを入れる先が無い媒体・形式では登録させない
|
|
5645
|
+
(入れ先が無いまま番号だけ入ると、入稿時にアップロード元が見つからない)。
|
|
5646
|
+
列は field 名で引く — 媒体ごとに名前が違う (YDA/Meta=thumbnailPath / TikTok=coverImagePath)。 */
|
|
5647
|
+
function newMediaFields(fmt){
|
|
5648
|
+
if(!fmt) return null;
|
|
5649
|
+
const has=f=>colDefs('ads').some(c=>c.field===f);
|
|
5650
|
+
if(fmt.apiMediaKind==='VIDEO'){
|
|
5651
|
+
if(!has('videoPath')) return null;
|
|
5652
|
+
const thumb=has('thumbnailPath')?'thumbnailPath':(has('coverImagePath')?'coverImagePath':'');
|
|
5653
|
+
return thumb ? {video:'videoPath', thumb} : null; // サムネの置き場が無ければ登録させない
|
|
5654
|
+
}
|
|
5655
|
+
return has('imagePath') ? {image:'imagePath'} : null;
|
|
5656
|
+
}
|
|
5657
|
+
|
|
5658
|
+
/** 計画内で使われているが参照表に無い管理番号 = 新規登録してまだ上げていない素材。 */
|
|
5659
|
+
function pendingMediaNos(){
|
|
5660
|
+
const store=st.refs.media||{}, seen=new Set();
|
|
5661
|
+
st.campaigns.forEach(c=>(c.adGroups||[]).forEach(g=>(g.ads||[]).forEach(a=>{
|
|
5662
|
+
const no=String(a.imageNo||'').trim();
|
|
5663
|
+
if(no!=='' && !store[no]) seen.add(no);
|
|
5664
|
+
})));
|
|
5665
|
+
Object.keys((st.project&&st.project.localMedia)||{}).forEach(no=>{ if(!store[no]) seen.add(no); });
|
|
5666
|
+
return [...seen].sort((a,b)=>Number(a)-Number(b));
|
|
5667
|
+
}
|
|
5668
|
+
|
|
5669
|
+
/** 空き番号 = 参照表と計画内で使われている番号の最大値 +1。
|
|
5670
|
+
⚠ `Math.max(...nums)` は使わない — 引数がスタックに積まれるので、
|
|
5671
|
+
参照表が大きいアカウントで `Maximum call stack size exceeded` になる。 */
|
|
5672
|
+
function nextMediaNo(){
|
|
5673
|
+
let max=0;
|
|
5674
|
+
Object.keys(st.refs.media||{}).concat(pendingMediaNos()).forEach(k=>{
|
|
5675
|
+
const n=Number(k); if(Number.isFinite(n) && n>max) max=n;
|
|
5676
|
+
});
|
|
5677
|
+
return String(max+1);
|
|
5466
5678
|
}
|
|
5467
5679
|
/* ============ 素材サムネ (#157) ============
|
|
5468
5680
|
媒体側の画像URLはどれも一時URL・CORS 制限つきで、プランナーから直接は表示できない。
|
|
@@ -5541,8 +5753,10 @@ function drawerPick(no){
|
|
|
5541
5753
|
const compat = compatColsFor(drawerTarget.type, sheetTab);
|
|
5542
5754
|
const rg=selRange(); let applied=0;
|
|
5543
5755
|
mutate(()=>{
|
|
5544
|
-
|
|
5545
|
-
if(
|
|
5756
|
+
/* この計画で新規登録した番号なら、番号に紐づくローカル素材のパスも一緒に入れる */
|
|
5757
|
+
const withPaths=item=>{ if(drawerTarget.type==='media') applyLocalMediaPaths(item, no); };
|
|
5758
|
+
if(rg){ const vis=flatVisible(); for(let rr=rg.r0; rr<=rg.r1; rr++){ const item=vis[rr]; if(!item) continue; for(let cc=rg.c0; cc<=rg.c1; cc++){ if(compat.indexOf(cc)>=0){ setVar(item,cc,num); withPaths(item); applied++; } } } }
|
|
5759
|
+
if(applied===0){ setField(drawerTarget.u,f,num); const fa=findAd(drawerTarget.u); if(fa) withPaths({kind:'a',a:fa.a,g:fa.g,c:fa.c}); applied=1; }
|
|
5546
5760
|
});
|
|
5547
5761
|
toast('番号 '+no+' を'+applied+'セルに入力');
|
|
5548
5762
|
closeDrawer();
|
|
@@ -5842,9 +6056,16 @@ function openModal(cfg){
|
|
|
5842
6056
|
const getVals=()=>{ const o={}; (cfg.fields||[]).forEach(f=>{ const el=document.getElementById(fid(f.key)); if(!el) return; o[f.key]= f.type==='check'? el.checked : el.value; }); return o; };
|
|
5843
6057
|
const setVal=(k,val)=>{ const el=document.getElementById(fid(k)); if(el) el.value=val; };
|
|
5844
6058
|
const close=()=>root.innerHTML='';
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
root.querySelector('[data-mo="
|
|
6059
|
+
/* onCancel: 閉じたときの後片付け (プレビュー用の ObjectURL の解放など) */
|
|
6060
|
+
const bail=()=>{ close(); if(cfg.onCancel) cfg.onCancel(); };
|
|
6061
|
+
root.querySelector('[data-mo="cancel"]').addEventListener('click',bail);
|
|
6062
|
+
root.querySelector('.overlay').addEventListener('mousedown',ev=>{ if(ev.target.classList.contains('overlay')) bail(); });
|
|
6063
|
+
root.querySelector('[data-mo="ok"]').addEventListener('click',()=>{
|
|
6064
|
+
const v=getVals();
|
|
6065
|
+
/* validate: 空文字なら通す / メッセージを返したら **閉じずに** 差し戻す */
|
|
6066
|
+
if(cfg.validate){ const msg=cfg.validate(v); if(msg){ toast(msg); return; } }
|
|
6067
|
+
close(); if(cfg.onOk) cfg.onOk(v);
|
|
6068
|
+
});
|
|
5848
6069
|
if(cfg.onMount) cfg.onMount(root, getVals, setVal);
|
|
5849
6070
|
const first=root.querySelector('input,select,textarea'); if(first) first.focus();
|
|
5850
6071
|
}
|
|
@@ -5909,13 +6130,66 @@ let _idbPromise=null;
|
|
|
5909
6130
|
function idb(){ if(!_idbPromise) _idbPromise=idbOpen(IDB_NAME).then(async db=>{ await migrateLegacyIdb(db); return db; }); return _idbPromise; }
|
|
5910
6131
|
async function idbSet(k,v){ const db=await idb(); return new Promise((res,rej)=>{ const tx=db.transaction('handles','readwrite'); tx.objectStore('handles').put(v,k); tx.oncomplete=res; tx.onerror=()=>rej(tx.error); }); }
|
|
5911
6132
|
async function idbGet(k){ const db=await idb(); return new Promise((res,rej)=>{ const tx=db.transaction('handles','readonly'); const rq=tx.objectStore('handles').get(k); rq.onsuccess=()=>res(rq.result); rq.onerror=()=>rej(rq.error); }); }
|
|
5912
|
-
|
|
5913
|
-
|
|
6133
|
+
/* ---- 入稿計画フォルダかどうかの判定 ----
|
|
6134
|
+
このフォルダは 素材/ の置き場所であり、「保存して入稿」で入稿計画ファイルを書き出す先でもある。
|
|
6135
|
+
保存後の入稿手順は `入稿計画/<file>` にある前提で CLI を案内し、CLI は計画ファイルの場所を基準に
|
|
6136
|
+
素材/ を探すので、**別のフォルダに接続すると本実行のアップロードまで誰も気づけない**。
|
|
6137
|
+
そのため 入稿計画/ 以外には接続させない。
|
|
6138
|
+
判定: 目印ファイル (PLANS_MARKER) があるか、フォルダ名が 入稿計画 か。
|
|
6139
|
+
名前で通すのは目印の無い旧配布物のためで、そのときは目印を足して以後は目印で判定する。 */
|
|
6140
|
+
const PLANS_DIR_NAME='入稿計画';
|
|
6141
|
+
const PLANS_MARKER='.ad-planner-plans';
|
|
6142
|
+
async function checkPlansDir(h){
|
|
6143
|
+
try{ await h.getFileHandle(PLANS_MARKER); return {ok:true}; }catch(e){}
|
|
6144
|
+
if(h.name===PLANS_DIR_NAME){
|
|
6145
|
+
try{ const fh=await h.getFileHandle(PLANS_MARKER,{create:true});
|
|
6146
|
+
await writeToHandle(fh,'ad-planner の入稿計画フォルダの目印です。消さないでください。\n'); }catch(e){}
|
|
6147
|
+
return {ok:true};
|
|
6148
|
+
}
|
|
6149
|
+
return {ok:false, reason:'選んだフォルダ「'+h.name+'」は入稿計画フォルダではありません。'
|
|
6150
|
+
+(plannerEnv.plansDir ? '下のパスのフォルダを選んでください' : 'ad-ops スキルの中にある「'+PLANS_DIR_NAME+'」フォルダを選んでください')};
|
|
6151
|
+
}
|
|
6152
|
+
/* ブラウザはフォルダの絶対パスを知る手段が無いので、配信元の adops.sh planner が書いた
|
|
6153
|
+
.planner-env.json から 入稿計画/ の場所を読んで案内に使う。別の方法で開いたときは無い。 */
|
|
6154
|
+
/* 読むのは「+ 新規登録」を押したときだけ。起動時に読むと、ファイルの無い環境 (CI・adops.sh を通さない開き方)
|
|
6155
|
+
で 404 がコンソールエラーとして出て、「起動時に JS エラーが出ない」系のテストが一斉に落ちる */
|
|
6156
|
+
let plannerEnv={}, plannerEnvLoaded=false;
|
|
6157
|
+
async function loadPlannerEnv(){
|
|
6158
|
+
if(plannerEnvLoaded) return; plannerEnvLoaded=true;
|
|
6159
|
+
try{ const r=await fetch('.planner-env.json',{cache:'no-store'}); if(r.ok) plannerEnv=(await r.json())||{}; }catch(e){}
|
|
6160
|
+
}
|
|
6161
|
+
async function adoptPlansDir(h){
|
|
6162
|
+
dirHandle=h; pendingDirHandle=null; await idbSet('dir',h); renderHist(); updateSubmitJsonBtn();
|
|
6163
|
+
}
|
|
6164
|
+
/** 入稿計画フォルダを選んで接続する。**クリック起点で呼ぶ** (ピッカーと権限の再要求はユーザー操作が要る)。
|
|
6165
|
+
入稿計画フォルダでなければ接続しない。fresh=true なら記憶中のフォルダの再許可を飛ばして選び直させる。
|
|
6166
|
+
@returns {{ok:boolean, name?:string, reconnected?:boolean, aborted?:boolean, reason?:string}} */
|
|
6167
|
+
async function pickPlansFolder(fresh){
|
|
6168
|
+
if(!fsSupported) return {ok:false, reason:'このブラウザはフォルダ連携に未対応です(Chrome 系で開いてください)'};
|
|
5914
6169
|
try{
|
|
5915
|
-
if(pendingDirHandle
|
|
5916
|
-
|
|
5917
|
-
|
|
5918
|
-
|
|
6170
|
+
if(pendingDirHandle && !fresh){
|
|
6171
|
+
const h=pendingDirHandle;
|
|
6172
|
+
const p=await h.requestPermission({mode:'readwrite'});
|
|
6173
|
+
if(p==='granted'){
|
|
6174
|
+
const c=await checkPlansDir(h);
|
|
6175
|
+
if(!c.ok){ pendingDirHandle=null; return {ok:false, reason:c.reason}; } // 記憶していたのが別のフォルダ
|
|
6176
|
+
await adoptPlansDir(h); return {ok:true, name:h.name, reconnected:true};
|
|
6177
|
+
}
|
|
6178
|
+
}
|
|
6179
|
+
const h=await window.showDirectoryPicker({id:'ad-planner-plans', mode:'readwrite'});
|
|
6180
|
+
const c=await checkPlansDir(h);
|
|
6181
|
+
if(!c.ok) return {ok:false, reason:c.reason};
|
|
6182
|
+
await adoptPlansDir(h); return {ok:true, name:h.name};
|
|
6183
|
+
}catch(e){
|
|
6184
|
+
if(e && e.name==='AbortError') return {ok:false, aborted:true};
|
|
6185
|
+
return {ok:false, reason:'フォルダ接続に失敗: '+(e&&e.message)};
|
|
6186
|
+
}
|
|
6187
|
+
}
|
|
6188
|
+
/** 履歴ビューの「📁 保存フォルダに接続」 */
|
|
6189
|
+
async function connectFolder(){
|
|
6190
|
+
const r=await pickPlansFolder(false);
|
|
6191
|
+
if(r.ok) toast(r.reconnected?'保存フォルダに再接続しました':'保存フォルダに接続しました: '+r.name);
|
|
6192
|
+
else if(!r.aborted) toast(r.reason, {sticky:true});
|
|
5919
6193
|
}
|
|
5920
6194
|
/* 入稿計画フォルダ未接続なら書き出しボタンを「接続」ボタンに切替(接続後は書き出し) */
|
|
5921
6195
|
function updateSubmitJsonBtn(){ /* ボタンは『サンプルプラン更新』に用途変更したためラベル切替は無効化 */ }
|
|
@@ -5923,8 +6197,14 @@ async function restoreFolder(){
|
|
|
5923
6197
|
if(!fsSupported) return;
|
|
5924
6198
|
try{ const h=await idbGet('dir'); if(!h) return;
|
|
5925
6199
|
const p=await h.queryPermission({mode:'readwrite'});
|
|
5926
|
-
if(p==='granted'){
|
|
5927
|
-
|
|
6200
|
+
if(p==='granted'){
|
|
6201
|
+
/* 記憶しているのが別のフォルダなら自動接続しない (すでに違うフォルダを選んでいた人のため) */
|
|
6202
|
+
const c=await checkPlansDir(h);
|
|
6203
|
+
if(c.ok){ dirHandle=h; toast('入稿計画フォルダ('+h.name+'/)に自動接続しました'); }
|
|
6204
|
+
else toast('記憶していた保存フォルダ「'+h.name+'」は入稿計画フォルダではないため、接続しませんでした。'
|
|
6205
|
+
+'素材の「+ 新規登録」から選び直してください', {sticky:true});
|
|
6206
|
+
}
|
|
6207
|
+
else pendingDirHandle=h; // 権限失効時はボタン押下(ユーザー操作)で1クリック再要求。判定は再許可のあと
|
|
5928
6208
|
}catch(e){}
|
|
5929
6209
|
if(activeView==='hist') renderHist();
|
|
5930
6210
|
}
|
|
@@ -5934,6 +6214,331 @@ async function writePlanFile(name, text){
|
|
|
5934
6214
|
catch(e){ toast('フォルダ書き込み失敗: '+e.message+'(ダウンロードに切替)'); return false; }
|
|
5935
6215
|
}
|
|
5936
6216
|
|
|
6217
|
+
/* ============================================================
|
|
6218
|
+
レスポンシブ動画の取り込み (#324)
|
|
6219
|
+
------------------------------------------------------------
|
|
6220
|
+
ブラウザは列に書いた「パス文字列」だけではローカル動画を読めないので、ファイルピッカーで
|
|
6221
|
+
選んでもらう。選んだ動画から **0 秒フレームを canvas で抜いてサムネにする**。
|
|
6222
|
+
YDA は動画からサムネを自動生成しないので (本番実測。connectors/yahooSubmission.ts uploadVideo
|
|
6223
|
+
のコメント参照)、入稿には必ずサムネ画像が要る。審査でサムネだけ落ちることがあるため、
|
|
6224
|
+
抽出位置(秒)を変えてやり直せるようにしている。
|
|
6225
|
+
|
|
6226
|
+
素材は入稿計画フォルダ配下の 素材/ に置き、列には **計画JSON基準の相対パス** を入れる
|
|
6227
|
+
(CLI の resolveLocalMediaPath が plan ファイルのディレクトリを基準に解決する)。
|
|
6228
|
+
接続フォルダの外にある動画はそこへコピーする — パスを自動で入れられないと
|
|
6229
|
+
「ファイルを選んだら両方埋まる」が成立しないため。
|
|
6230
|
+
============================================================ */
|
|
6231
|
+
const MEDIA_SUBDIR='素材';
|
|
6232
|
+
|
|
6233
|
+
/** 動画の指定秒のフレームを JPEG Blob で返す。
|
|
6234
|
+
sec=0 は seek せず loadeddata のフレームを使う (currentTime=0 の代入では seeked が
|
|
6235
|
+
発火しないブラウザがあり、待ち続けて固まるため)。 */
|
|
6236
|
+
function grabVideoFrame(file, sec){
|
|
6237
|
+
return new Promise((resolve, reject)=>{
|
|
6238
|
+
const url=URL.createObjectURL(file);
|
|
6239
|
+
const v=document.createElement('video');
|
|
6240
|
+
v.preload='auto'; v.muted=true; v.playsInline=true;
|
|
6241
|
+
let settled=false;
|
|
6242
|
+
const fail=e=>{ if(settled) return; settled=true; URL.revokeObjectURL(url); reject(e instanceof Error?e:new Error('動画を読み込めません: '+file.name)); };
|
|
6243
|
+
const shoot=()=>{
|
|
6244
|
+
if(settled) return;
|
|
6245
|
+
try{
|
|
6246
|
+
const c=document.createElement('canvas');
|
|
6247
|
+
c.width=v.videoWidth; c.height=v.videoHeight;
|
|
6248
|
+
if(!c.width||!c.height) return fail(new Error('動画のサイズを取得できません: '+file.name));
|
|
6249
|
+
c.getContext('2d').drawImage(v,0,0,c.width,c.height);
|
|
6250
|
+
c.toBlob(b=>{ if(settled) return; settled=true; URL.revokeObjectURL(url);
|
|
6251
|
+
b?resolve({blob:b, width:c.width, height:c.height, duration:v.duration||0}):reject(new Error('サムネを作れません: '+file.name)); },
|
|
6252
|
+
'image/jpeg', 0.92);
|
|
6253
|
+
}catch(e){ fail(e); }
|
|
6254
|
+
};
|
|
6255
|
+
v.onerror=()=>fail(new Error('動画を読み込めません(対応していない形式かもしれません): '+file.name));
|
|
6256
|
+
v.onseeked=shoot;
|
|
6257
|
+
v.onloadeddata=()=>{
|
|
6258
|
+
const want=Math.max(0, Number(sec)||0);
|
|
6259
|
+
const max=Math.max(0,(v.duration||0)-0.05);
|
|
6260
|
+
const at=Math.min(want, max);
|
|
6261
|
+
if(at<=0) shoot(); else { try{ v.currentTime=at; }catch(e){ fail(e); } }
|
|
6262
|
+
};
|
|
6263
|
+
v.src=url;
|
|
6264
|
+
});
|
|
6265
|
+
}
|
|
6266
|
+
|
|
6267
|
+
/** 入稿計画フォルダ配下 素材/ にファイルを書き、計画JSON基準の相対パスを返す */
|
|
6268
|
+
async function writeMediaFile(name, data){
|
|
6269
|
+
if(!dirHandle) throw new Error('入稿計画フォルダに接続していません(素材の「+ 新規登録」から保存フォルダを選んでください)');
|
|
6270
|
+
const dir=await dirHandle.getDirectoryHandle(MEDIA_SUBDIR,{create:true});
|
|
6271
|
+
const fh=await dir.getFileHandle(name,{create:true});
|
|
6272
|
+
const w=await fh.createWritable(); await w.write(data); await w.close();
|
|
6273
|
+
return MEDIA_SUBDIR+'/'+name;
|
|
6274
|
+
}
|
|
6275
|
+
/** 既に接続フォルダの中にある動画はコピーせず、その相対パスをそのまま使う */
|
|
6276
|
+
async function videoRelPath(handle, file){
|
|
6277
|
+
try{
|
|
6278
|
+
const seg=await dirHandle.resolve(handle);
|
|
6279
|
+
if(seg&&seg.length) return seg.join('/');
|
|
6280
|
+
}catch(e){}
|
|
6281
|
+
return await writeMediaFile(safeFileName(file.name), file);
|
|
6282
|
+
}
|
|
6283
|
+
/** ファイル名に使えない文字を落とす (広告名や日本語はそのまま通す) */
|
|
6284
|
+
function safeFileName(n){ return String(n||'file').replace(/[\/\\:*?"<>|]/g,'_'); }
|
|
6285
|
+
|
|
6286
|
+
/* ---- サイドバーの「+ 新規登録」 ----
|
|
6287
|
+
画像も動画も**番号セルの右サイドバーから**登録する (ツールバーに別動線を作らない)。
|
|
6288
|
+
参照表に無い番号を行に入れ、素材は 素材/ へ置いてパス列に入れるだけ。mediaID は
|
|
6289
|
+
入稿の直前に CLI がアップロードして解決するので、ここでは refs.media に触らない。
|
|
6290
|
+
|
|
6291
|
+
⚠ showOpenFilePicker / showDirectoryPicker は**ユーザー操作の直後**にしか開けない
|
|
6292
|
+
(await を挟むと失効する。フォルダを選んだ直後に続けてファイル選択を開くと失敗する)。
|
|
6293
|
+
そのため「① 保存フォルダ → ② 素材」をモーダルのボタンで1クリックずつ進めてもらい、
|
|
6294
|
+
番号の確認はファイルを選び終わってから出す。 */
|
|
6295
|
+
async function openNewMediaWizard(){
|
|
6296
|
+
if(!drawerTarget || !drawerTarget.u){ return; }
|
|
6297
|
+
const target={u:drawerTarget.u, f:drawerTarget.f}; // await の前に確定させる
|
|
6298
|
+
await loadPlannerEnv();
|
|
6299
|
+
/* ダイアログを開いている間に drawer が閉じられると drawerTarget が null になる。
|
|
6300
|
+
**対象の行はここで確定させて持ち回る** (await をまたいで drawerTarget を読まない)。 */
|
|
6301
|
+
const fa=findAd(target.u); if(!fa) return;
|
|
6302
|
+
const fmt=pf().fmtOf(fa.a);
|
|
6303
|
+
const isVideo=fmt && fmt.apiMediaKind==='VIDEO';
|
|
6304
|
+
const kind=isVideo?'動画':'画像';
|
|
6305
|
+
if(!fsSupported){ toast('このブラウザはローカルフォルダ連携に未対応です(Chrome 系で開いてください)'); return; }
|
|
6306
|
+
|
|
6307
|
+
const root=document.getElementById('modalRoot');
|
|
6308
|
+
let error='', busy=false;
|
|
6309
|
+
const close=()=>{ root.innerHTML=''; };
|
|
6310
|
+
/* キャンセルか、入稿計画フォルダを選んで素材まで進むまで閉じない (オーバーレイのクリックでも閉じない) */
|
|
6311
|
+
const render=()=>{
|
|
6312
|
+
const ok=!!dirHandle;
|
|
6313
|
+
const step1 = ok
|
|
6314
|
+
? '<div class="nmstep done"><div class="nmhead"><span class="nmno">✓</span>保存フォルダ</div>'
|
|
6315
|
+
+'<div class="nmbody"><span class="mono">'+esc(dirHandle.name)+'/</span>'
|
|
6316
|
+
+'<button class="act ghost" data-nm="dirfresh">変更</button></div></div>'
|
|
6317
|
+
: '<div class="nmstep cur"><div class="nmhead"><span class="nmno">1</span>保存フォルダ('+PLANS_DIR_NAME+')を選ぶ'
|
|
6318
|
+
+'<span class="pill">初期設定(初回のみ)</span></div>'
|
|
6319
|
+
+'<div class="nmbody"><div class="hint">最初の1回だけの設定です。次回からは自動でつながります。'
|
|
6320
|
+
+'素材('+MEDIA_SUBDIR+'/)と入稿計画ファイルの保存先になります。</div>'
|
|
6321
|
+
+(plannerEnv.plansDir
|
|
6322
|
+
? '<div class="nmpath"><span class="mono">'+esc(plannerEnv.plansDir)+'</span>'
|
|
6323
|
+
+'<button class="act ghost" data-nm="copypath">パスをコピー</button></div>'
|
|
6324
|
+
+'<div class="hint">「保存フォルダを選ぶ」で開く画面で <b>Cmd+Shift+G</b>(Windows はアドレス欄)にこのパスを貼り付けて移動し、そのまま「選択」を押してください</div>'
|
|
6325
|
+
: '<div class="hint">ad-ops スキルの中にある「'+PLANS_DIR_NAME+'」フォルダを選んでください</div>')
|
|
6326
|
+
+(pendingDirHandle
|
|
6327
|
+
? '<button class="act primary" data-nm="dir">前回のフォルダ('+esc(pendingDirHandle.name)+')を再許可する</button>'
|
|
6328
|
+
+'<button class="act ghost" data-nm="dirfresh">別のフォルダを選ぶ</button>'
|
|
6329
|
+
: '<button class="act primary" data-nm="dir">保存フォルダを選ぶ</button>')
|
|
6330
|
+
+'</div></div>';
|
|
6331
|
+
const step2 = '<div class="nmstep '+(ok?'cur':'wait')+'"><div class="nmhead"><span class="nmno">2</span>'+kind+'を選ぶ</div>'
|
|
6332
|
+
+'<div class="nmbody"><div class="hint">'+(isVideo
|
|
6333
|
+
? '選んだ動画から 0 秒目のフレームをサムネイルとして切り出します(次の画面で確認・変更できます)'
|
|
6334
|
+
: '選んだ画像を '+MEDIA_SUBDIR+'/ に置き、入稿時にアップロードします')
|
|
6335
|
+
+'。<b>複数選べます</b>(選択中の行に上から順に入ります)</div>'
|
|
6336
|
+
+'<button class="act primary" data-nm="file"'+(ok?'':' disabled')+'>'+kind+'を選ぶ</button>'
|
|
6337
|
+
+(ok?'':'<span class="sm" style="color:var(--faint)"> 先に保存フォルダを選んでください</span>')
|
|
6338
|
+
+'</div></div>';
|
|
6339
|
+
root.innerHTML='<div class="overlay"><div class="modal" id="newMediaWizard"><h3>'+kind+'を新規登録</h3>'
|
|
6340
|
+
+'<div class="msub">保存フォルダを決めてから、'+kind+'を選びます</div>'
|
|
6341
|
+
+'<div class="mbody">'+(error?'<div class="callout danger" data-nm-error>'+esc(error)+'</div>':'')+step1+step2+'</div>'
|
|
6342
|
+
+'<div class="mfoot"><button class="act" data-nm="cancel">キャンセル</button></div></div></div>';
|
|
6343
|
+
root.querySelector('[data-nm="cancel"]').addEventListener('click', close);
|
|
6344
|
+
const copyBtn=root.querySelector('[data-nm="copypath"]');
|
|
6345
|
+
if(copyBtn) copyBtn.addEventListener('click', async ()=>{
|
|
6346
|
+
try{ await navigator.clipboard.writeText(plannerEnv.plansDir); copyBtn.textContent='コピーしました'; }
|
|
6347
|
+
catch(e){ toast('コピーできませんでした。パスを選択してコピーしてください'); }
|
|
6348
|
+
});
|
|
6349
|
+
root.querySelectorAll('[data-nm="dir"],[data-nm="dirfresh"]').forEach(b=>b.addEventListener('click', async ()=>{
|
|
6350
|
+
if(busy) return; busy=true;
|
|
6351
|
+
let r;
|
|
6352
|
+
try{ r=await pickPlansFolder(b.dataset.nm==='dirfresh'); }
|
|
6353
|
+
catch(e){ r={ok:false, reason:'フォルダ接続に失敗: '+(e&&e.message)}; }
|
|
6354
|
+
finally{ busy=false; }
|
|
6355
|
+
console.info('[planner] 保存フォルダの選択:', r);
|
|
6356
|
+
/* 黙って元の画面に戻すと「選んだのに進まない」ように見える。キャンセルも理由を出す
|
|
6357
|
+
(フォルダ選択のあとに出る「編集を許可」を閉じてもキャンセル扱いになる) */
|
|
6358
|
+
if(r.ok) error='';
|
|
6359
|
+
else error = r.aborted
|
|
6360
|
+
? 'フォルダの選択、または続けて出る「ファイルの編集を許可」がキャンセルされました。もう一度「保存フォルダを選ぶ」を押し、最後に「編集を許可」を押してください'
|
|
6361
|
+
: r.reason;
|
|
6362
|
+
render();
|
|
6363
|
+
if(r.ok){ try{ renderDrawer(); }catch(e){ recordError('drawer', e, 'サイドバーの再描画'); } }
|
|
6364
|
+
}));
|
|
6365
|
+
const fileBtn=root.querySelector('[data-nm="file"]');
|
|
6366
|
+
if(fileBtn) fileBtn.addEventListener('click', ()=>{
|
|
6367
|
+
if(!dirHandle || busy) return;
|
|
6368
|
+
/* ここで await を挟まずにピッカーを開く (ユーザー操作の有効期限内に呼ぶ) */
|
|
6369
|
+
pickNewMediaFiles(isVideo).then(handles=>{
|
|
6370
|
+
if(!handles.length) return; // ピッカーをキャンセル → モーダルに戻る
|
|
6371
|
+
close();
|
|
6372
|
+
continueNewMedia({handles, isVideo, fmt, target});
|
|
6373
|
+
});
|
|
6374
|
+
});
|
|
6375
|
+
};
|
|
6376
|
+
render();
|
|
6377
|
+
}
|
|
6378
|
+
/** 素材ファイルを選ばせる (複数可)。キャンセルなら [] */
|
|
6379
|
+
async function pickNewMediaFiles(isVideo){
|
|
6380
|
+
try{
|
|
6381
|
+
const handles = isVideo
|
|
6382
|
+
? await window.showOpenFilePicker({ id:'ad-planner-video', multiple:true,
|
|
6383
|
+
types:[{description:'動画', accept:{'video/mp4':['.mp4'],'video/quicktime':['.mov']}}] })
|
|
6384
|
+
: await window.showOpenFilePicker({ id:'ad-planner-image', multiple:true,
|
|
6385
|
+
types:[{description:'画像', accept:{'image/jpeg':['.jpg','.jpeg'],'image/png':['.png']}}] });
|
|
6386
|
+
return handles || [];
|
|
6387
|
+
}catch(e){ return []; } // ユーザーがキャンセル
|
|
6388
|
+
}
|
|
6389
|
+
|
|
6390
|
+
/* ---- この計画で新規登録した素材 (管理番号 → ローカルパス) ----
|
|
6391
|
+
パスは「登録した行」ではなく**番号**に持つ。こうしないと、同じ番号を別の行で選んだときに
|
|
6392
|
+
パスが付かず、一覧にもサムネを出せない。mediaID がまだ無いので refs.media には入れない
|
|
6393
|
+
(空の ID を入れると「登録済み」と誤判定され、CLI のアップロードが走らない)。
|
|
6394
|
+
st.project に置くので保存 JSON と往復する (入稿計画JSONには出ない)。 */
|
|
6395
|
+
function localMedia(){
|
|
6396
|
+
if(!st.project.localMedia || typeof st.project.localMedia!=='object') st.project.localMedia={};
|
|
6397
|
+
return st.project.localMedia;
|
|
6398
|
+
}
|
|
6399
|
+
/** 番号に登録したローカル素材のパスを、その行のパス列へ入れる (行の形式と素材の種類が合うときだけ) */
|
|
6400
|
+
function applyLocalMediaPaths(item, no){
|
|
6401
|
+
if(!item || item.kind!=='a') return;
|
|
6402
|
+
const e=localMedia()[String(no)]; if(!e) return;
|
|
6403
|
+
const F=newMediaFields(pf().fmtOf(item.a)); if(!F) return;
|
|
6404
|
+
if(F.video && e.video){ setVarByField(item,'ads',F.video,e.video); setVarByField(item,'ads',F.thumb,e.thumb||''); }
|
|
6405
|
+
else if(F.image && e.image){ setVarByField(item,'ads',F.image,e.image); }
|
|
6406
|
+
}
|
|
6407
|
+
/* ローカル素材のサムネ。接続フォルダから読んで ObjectURL にする (登録直後は手元の Blob をそのまま使う) */
|
|
6408
|
+
const localThumbUrls=new Map();
|
|
6409
|
+
async function localFileUrl(rel){
|
|
6410
|
+
if(localThumbUrls.has(rel)) return localThumbUrls.get(rel);
|
|
6411
|
+
if(!dirHandle || !rel) return '';
|
|
6412
|
+
try{
|
|
6413
|
+
const parts=String(rel).split('/'); let d=dirHandle;
|
|
6414
|
+
for(let i=0;i<parts.length-1;i++) d=await d.getDirectoryHandle(parts[i]);
|
|
6415
|
+
const f=await (await d.getFileHandle(parts[parts.length-1])).getFile();
|
|
6416
|
+
const u=URL.createObjectURL(f); localThumbUrls.set(rel,u); return u;
|
|
6417
|
+
}catch(e){ return ''; }
|
|
6418
|
+
}
|
|
6419
|
+
function hydrateLocalThumbs(root){
|
|
6420
|
+
root.querySelectorAll('img[data-localthumb]').forEach(async img=>{
|
|
6421
|
+
const u=await localFileUrl(img.dataset.localthumb);
|
|
6422
|
+
if(u) img.src=u; else img.remove();
|
|
6423
|
+
});
|
|
6424
|
+
}
|
|
6425
|
+
|
|
6426
|
+
/** 新規登録の対象行。範囲選択中なら「選択範囲の広告行のうち、同じ種類 (動画 / 画像) の行」を上から順に。
|
|
6427
|
+
選択が無ければ ▾ を押した行だけ。 */
|
|
6428
|
+
function newMediaTargets(target, isVideo){
|
|
6429
|
+
const rg=selRange(), vis=flatVisible(), out=[];
|
|
6430
|
+
if(rg && rg.r1>rg.r0){
|
|
6431
|
+
for(let r=rg.r0;r<=rg.r1;r++){
|
|
6432
|
+
const it=vis[r]; if(!it || it.kind!=='a') continue;
|
|
6433
|
+
const f=pf().fmtOf(it.a); if(!f || (f.apiMediaKind==='VIDEO')!==isVideo) continue;
|
|
6434
|
+
out.push({u:it.a.uid, row:r+1});
|
|
6435
|
+
}
|
|
6436
|
+
}
|
|
6437
|
+
if(!out.length){ const r=vis.findIndex(it=>it && it.kind==='a' && it.a.uid===target.u); out.push({u:target.u, row:r+1}); }
|
|
6438
|
+
return out;
|
|
6439
|
+
}
|
|
6440
|
+
|
|
6441
|
+
/** 選んだ素材から (動画なら 0 秒サムネを抜いて) 番号と行を確認するモーダルへ進む */
|
|
6442
|
+
async function continueNewMedia({handles, isVideo, fmt, target}){
|
|
6443
|
+
const items=[];
|
|
6444
|
+
for(const handle of handles){
|
|
6445
|
+
const file=await handle.getFile();
|
|
6446
|
+
let frame=null;
|
|
6447
|
+
if(isVideo){
|
|
6448
|
+
try{ frame=await grabVideoFrame(file, 0); }
|
|
6449
|
+
catch(e){ recordError('videoFrame', e, '動画サムネの抽出'); toast('動画サムネを作れませんでした('+file.name+'): '+e.message); return; }
|
|
6450
|
+
}
|
|
6451
|
+
items.push({handle, file, frame, sec:0});
|
|
6452
|
+
}
|
|
6453
|
+
if(!items.length) return;
|
|
6454
|
+
openNewMediaModal({items, isVideo, fmt, target, targets:newMediaTargets(target, isVideo)});
|
|
6455
|
+
}
|
|
6456
|
+
|
|
6457
|
+
/** 管理番号と入れる行を決めるモーダル。動画はサムネのプレビューと抽出位置もここで出す。
|
|
6458
|
+
番号は空き番号からの連番を初期値に出し、書き換えられる。行より素材が多い分は一覧にだけ登録する。 */
|
|
6459
|
+
function openNewMediaModal(ctx){
|
|
6460
|
+
const n0=Number(nextMediaNo());
|
|
6461
|
+
ctx.items.forEach((it,i)=>{ it.no=String(n0+i); it.row=ctx.targets[i]||null; });
|
|
6462
|
+
const urls=ctx.items.map(it=>URL.createObjectURL(ctx.isVideo?it.frame.blob:it.file));
|
|
6463
|
+
const revoke=()=>urls.forEach(u=>{ try{ URL.revokeObjectURL(u); }catch(e){} });
|
|
6464
|
+
const kind=ctx.isVideo?'動画':'画像';
|
|
6465
|
+
const root=document.getElementById('modalRoot');
|
|
6466
|
+
const close=()=>{ root.innerHTML=''; };
|
|
6467
|
+
const rowsHtml=()=>ctx.items.map((it,i)=>'<div class="nmitem" data-i="'+i+'">'
|
|
6468
|
+
+'<img src="'+esc(urls[i])+'" alt="">'
|
|
6469
|
+
+'<div class="nmmeta"><div class="mono nmfile">'+esc(it.file.name)+'</div>'
|
|
6470
|
+
+'<div class="nmfields"><label>管理番号 <input type="text" class="nm-no" value="'+esc(it.no)+'"></label>'
|
|
6471
|
+
+(ctx.isVideo?'<label>サムネ位置(秒) <input type="number" min="0" class="nm-sec" value="'+it.sec+'"></label>':'')
|
|
6472
|
+
+'</div><div class="hint">'+(it.row?('広告 '+it.row.row+' 行目に入れます'):'行が足りないため、一覧にだけ登録します(あとで他の行から選べます)')+'</div></div></div>').join('');
|
|
6473
|
+
root.innerHTML='<div class="overlay"><div class="modal" id="newMediaModal"><h3>'+kind+'を新規登録('+ctx.items.length+'件)</h3>'
|
|
6474
|
+
+'<div class="msub">番号は空き番号からの連番です。広告名に出る番号なので、運用の番号に変えられます</div>'
|
|
6475
|
+
+'<div class="mbody">'+rowsHtml()+'</div>'
|
|
6476
|
+
+'<div class="mfoot"><button class="act" data-mo="cancel">キャンセル</button><button class="act primary" data-mo="ok">登録する</button></div></div></div>';
|
|
6477
|
+
root.querySelector('[data-mo="cancel"]').addEventListener('click',()=>{ close(); revoke(); });
|
|
6478
|
+
root.querySelectorAll('.nm-sec').forEach(el=>el.addEventListener('change', async ()=>{
|
|
6479
|
+
const i=Number(el.closest('.nmitem').dataset.i), it=ctx.items[i];
|
|
6480
|
+
const sec=Math.max(0, Number(el.value)||0);
|
|
6481
|
+
try{
|
|
6482
|
+
it.frame=await grabVideoFrame(it.file, sec); it.sec=sec;
|
|
6483
|
+
try{ URL.revokeObjectURL(urls[i]); }catch(e){}
|
|
6484
|
+
urls[i]=URL.createObjectURL(it.frame.blob);
|
|
6485
|
+
el.closest('.nmitem').querySelector('img').src=urls[i];
|
|
6486
|
+
}catch(e){ recordError('videoFrame', e, '動画サムネの再抽出'); toast('サムネを取り直せませんでした: '+e.message); }
|
|
6487
|
+
}));
|
|
6488
|
+
root.querySelector('[data-mo="ok"]').addEventListener('click', async ()=>{
|
|
6489
|
+
const nos=[...root.querySelectorAll('.nm-no')].map(el=>String(el.value||'').trim());
|
|
6490
|
+
/* 既存番号への上書きは許さない。参照表の mediaID を持つ番号にパスが被さると、
|
|
6491
|
+
どちらが入稿に使われるか画面から分からなくなる。 */
|
|
6492
|
+
for(const no of nos){
|
|
6493
|
+
if(no==='') return toast('管理番号を入れてください');
|
|
6494
|
+
if((st.refs.media||{})[no]) return toast('番号 '+no+' は参照データに登録済みです。別の番号にしてください');
|
|
6495
|
+
if(localMedia()[no]) return toast('番号 '+no+' はこの計画で登録済みです。別の番号にしてください');
|
|
6496
|
+
}
|
|
6497
|
+
if(new Set(nos).size!==nos.length) return toast('同じ管理番号が重複しています');
|
|
6498
|
+
ctx.items.forEach((it,i)=>{ it.no=nos[i]; });
|
|
6499
|
+
close(); revoke();
|
|
6500
|
+
for(const it of ctx.items){
|
|
6501
|
+
const ok=await applyNewMedia({isVideo:ctx.isVideo, fmt:ctx.fmt, file:it.file, handle:it.handle, frame:it.frame, sec:it.sec,
|
|
6502
|
+
target:it.row?{u:it.row.u, f:ctx.target.f}:null}, it.no, {quiet:true});
|
|
6503
|
+
if(!ok) return;
|
|
6504
|
+
}
|
|
6505
|
+
const placed=ctx.items.filter(it=>it.row).length;
|
|
6506
|
+
toast(ctx.items.length+'件を登録しました(行に入れたもの '+placed+'件・'+MEDIA_SUBDIR+'/ に書き出し。入稿時にアップロードされます)');
|
|
6507
|
+
});
|
|
6508
|
+
}
|
|
6509
|
+
|
|
6510
|
+
/** 素材を 素材/ へ書き出して番号に登録し、target があればその行に番号とパスを入れる。
|
|
6511
|
+
@returns {Promise<boolean>} 書き出せたか */
|
|
6512
|
+
async function applyNewMedia(ctx, no, opts){
|
|
6513
|
+
const F=newMediaFields(ctx.fmt); if(!F) return false;
|
|
6514
|
+
const entry={file:ctx.file.name};
|
|
6515
|
+
try{
|
|
6516
|
+
if(ctx.isVideo){
|
|
6517
|
+
const base=safeFileName(ctx.file.name).replace(/\.[^.]+$/,'');
|
|
6518
|
+
entry.video=await videoRelPath(ctx.handle, ctx.file);
|
|
6519
|
+
entry.thumb=await writeMediaFile(base+'_'+ctx.sec+'s.jpg', ctx.frame.blob);
|
|
6520
|
+
localThumbUrls.set(entry.thumb, URL.createObjectURL(ctx.frame.blob));
|
|
6521
|
+
}else{
|
|
6522
|
+
entry.image=await videoRelPath(ctx.handle, ctx.file);
|
|
6523
|
+
localThumbUrls.set(entry.image, URL.createObjectURL(ctx.file));
|
|
6524
|
+
}
|
|
6525
|
+
}catch(e){ recordError('mediaWrite', e, '素材の書き出し'); toast('素材を書き出せませんでした: '+e.message); return false; }
|
|
6526
|
+
|
|
6527
|
+
mutate(()=>{ localMedia()[String(no)]=entry; });
|
|
6528
|
+
if(ctx.target){
|
|
6529
|
+
const fa=findAd(ctx.target.u);
|
|
6530
|
+
if(!fa){ toast('対象の広告行が見つかりません'); return false; }
|
|
6531
|
+
/* ⚠ setVar の第1引数は **flatVisible() の行オブジェクト**(`rowOf` が kind で実体を選ぶ)。
|
|
6532
|
+
広告オブジェクトを直接渡すと `rowOf` が行を取れず、**黙って何も書かずに返る**。 */
|
|
6533
|
+
const item={kind:'a', a:fa.a, g:fa.g, c:fa.c};
|
|
6534
|
+
const num=/^\d+$/.test(String(no))?Number(no):no;
|
|
6535
|
+
mutate(()=>{ setVarByField(item,'ads',ctx.target.f||'imageNo',num); applyLocalMediaPaths(item, no); });
|
|
6536
|
+
}
|
|
6537
|
+
if(!(opts&&opts.quiet)) toast('番号 '+no+' で登録しました('+MEDIA_SUBDIR+'/ に書き出し。入稿時にアップロードされます)');
|
|
6538
|
+
if(typeof renderDrawer==='function') renderDrawer();
|
|
6539
|
+
return true;
|
|
6540
|
+
}
|
|
6541
|
+
|
|
5937
6542
|
/* ============================================================
|
|
5938
6543
|
B-1: アカウント別参照データ (アカウント/<媒体>/ フォルダ + <名前>.json)
|
|
5939
6544
|
File System Access API 前提 (Chrome系・フォールバックなし)。未接続時は同梱デフォルト(<account.dir>/index.json 先頭)を使用。
|
|
@@ -6092,6 +6697,7 @@ window.applyAccountJson=function(obj){
|
|
|
6092
6697
|
st.campaigns.forEach(c=>{ c.uid=uid('c'); (c.adGroups||[]).forEach(g=>{ g.uid=uid('g'); (g.ads||[]).forEach(a=>{ a.uid=uid('a'); }); }); });
|
|
6093
6698
|
migrateArticleSlug(st.campaigns); // C8: 旧 articleSlug / articleUrl を除去
|
|
6094
6699
|
migratePlatformValues(st.campaigns); // 媒体側の旧値を現行の語彙へ
|
|
6700
|
+
st.project.localMedia=samplePlanLocalMedia();
|
|
6095
6701
|
planMismatchMsg='';
|
|
6096
6702
|
if(typeof renderPlanWarn==='function') renderPlanWarn();
|
|
6097
6703
|
}
|
|
@@ -6447,14 +7053,27 @@ function openHelpModal(){
|
|
|
6447
7053
|
|
|
6448
7054
|
/* 「サンプルプラン更新」: 現在のプラン(共通設定+キャンペーン)を サンプルプラン.json に書き戻す。
|
|
6449
7055
|
初回だけ File System Access でファイルを選び、以後は IndexedDB 保持ハンドルで Finder無し更新。 */
|
|
7056
|
+
/** サンプルプラン.json に書き戻す中身。
|
|
7057
|
+
localMedia (この計画で新規登録した素材: 番号 → ローカルパス) も持つ。無いと保存して開き直したときに、
|
|
7058
|
+
一覧にだけ登録した素材と、番号に紐づくパス・サムネが消える (行に入ったパスは campaigns 側に残る)。 */
|
|
7059
|
+
function samplePlanData(){
|
|
7060
|
+
const data={schemaVersion:SCHEMA_VERSION, platform:curPf, accountId:st.project.accountId||'', campaigns:st.campaigns};
|
|
7061
|
+
const lm=st.project.localMedia;
|
|
7062
|
+
if(lm && typeof lm==='object' && Object.keys(lm).length) data.localMedia=lm;
|
|
7063
|
+
return data;
|
|
7064
|
+
}
|
|
7065
|
+
/** サンプルプランの localMedia を読む (無い・壊れているときは空) */
|
|
7066
|
+
function samplePlanLocalMedia(){
|
|
7067
|
+
const lm=samplePlan && samplePlan.localMedia;
|
|
7068
|
+
return (lm && typeof lm==='object' && !Array.isArray(lm)) ? JSON.parse(JSON.stringify(lm)) : {};
|
|
7069
|
+
}
|
|
6450
7070
|
async function updateSamplePlan(){
|
|
6451
7071
|
/* S13: 書き出しスキーマは同梱の サンプルプラン*.json と同形 {schemaVersion, platform, accountId, campaigns} に揃える。
|
|
6452
7072
|
・accountId が無いと initialState() のアカウント不一致ガードが素通りし、別アカウントのプランが
|
|
6453
7073
|
無警告で読み込まれる(誤入稿に直結)ので必ず出す
|
|
6454
7074
|
・project は読み戻し側が存在しない(案件設定はアカウントJSON由来で defaultProject() が作る)。
|
|
6455
7075
|
書いても次回起動で必ず捨てられるため出さない = 「保存したのに戻る」の誤解を断つ */
|
|
6456
|
-
const
|
|
6457
|
-
const json=JSON.stringify(data,null,1);
|
|
7076
|
+
const json=JSON.stringify(samplePlanData(),null,1);
|
|
6458
7077
|
if(!samplePlanFileHandle){ try{ const h=await idbGet(idbKeySamplePlan()); if(h){ const perm=await h.queryPermission({mode:'readwrite'}); if(perm==='granted') samplePlanFileHandle=h; else samplePlanFileHandle=h; } }catch(e){} }
|
|
6459
7078
|
if(samplePlanFileHandle){ try{ let perm=await samplePlanFileHandle.queryPermission({mode:'readwrite'}); if(perm!=='granted') perm=await samplePlanFileHandle.requestPermission({mode:'readwrite'}); if(perm!=='granted') samplePlanFileHandle=null; }catch(e){ samplePlanFileHandle=null; } }
|
|
6460
7079
|
if(!samplePlanFileHandle){
|
|
@@ -6562,7 +7181,7 @@ document.getElementById('sheetFilter').addEventListener('change',()=>{ clearSel(
|
|
|
6562
7181
|
document.getElementById('modeSeg').addEventListener('click',e=>{ const b=e.target.closest('button[data-m]'); if(!b) return; clearSel(); closeDrawer(); planMode=b.dataset.m; document.querySelectorAll('#modeSeg button').forEach(x=>x.classList.toggle('active',x===b)); render(); });
|
|
6563
7182
|
/* S11: 媒体セレクタ (#pfSeg) / 媒体要件タブ (#mediaTabs) は廃止。媒体切替はヘッダーの統合プルダウンに一本化 */
|
|
6564
7183
|
/* YDA内: 概要 / レスポンシブ / バナー / パラメータ / 使い方 タブ切替 */
|
|
6565
|
-
document.getElementById('ydaTabs').addEventListener('click',e=>{ const b=e.target.closest('button[data-yt]'); if(!b) return; document.querySelectorAll('#ydaTabs button').forEach(x=>x.classList.toggle('active',x===b)); ['overview','resp','banner','param','guide'].forEach(k=>{ document.getElementById('yt-'+k).style.display = b.dataset.yt===k?'':'none'; }); });
|
|
7184
|
+
document.getElementById('ydaTabs').addEventListener('click',e=>{ const b=e.target.closest('button[data-yt]'); if(!b) return; document.querySelectorAll('#ydaTabs button').forEach(x=>x.classList.toggle('active',x===b)); ['overview','resp','banner','video','param','guide'].forEach(k=>{ document.getElementById('yt-'+k).style.display = b.dataset.yt===k?'':'none'; }); });
|
|
6566
7185
|
/* 案件テンプレ: チップ直接編集 */
|
|
6567
7186
|
document.getElementById('tplChips').addEventListener('change',e=>{
|
|
6568
7187
|
/* S12: kind:'datetime' は日付/時刻の2入力で1つのキーを作るので、[data-proj] の汎用処理より先に分岐する
|
|
@@ -6674,7 +7293,7 @@ sheetHostEl.addEventListener('mousedown',e=>{
|
|
|
6674
7293
|
pendingDrawer=null; dragMoved=false;
|
|
6675
7294
|
// S3: 旧 [1,2,3] リテラルを撤去。列記述子の kind で「▼参照ドロワー列」「ターゲティング列」を判定する
|
|
6676
7295
|
const col=colDefs()[c];
|
|
6677
|
-
if(e.shiftKey && selA){ selB={r,c}; e.preventDefault(); } else { selA={r,c}; selB={r,c}; dragging='cell'; if(col && col.kind==='refcombo') pendingDrawer={kind:'ad', u:cell.dataset.u, f:cell.dataset.f}; else if(col && (col.kind==='tgpick'||col.kind==='refpick') && cell.dataset.gf) pendingDrawer={kind:'tg', gu:cell.dataset.gu, gf:cell.dataset.gf}; else if(col && col.kind==='optpick' && cell.dataset.f) pendingDrawer={kind:'opt', u:cell.dataset.u, f:cell.dataset.f}; }
|
|
7296
|
+
if(e.shiftKey && selA){ selB={r,c}; e.preventDefault(); /* Shift+クリックの範囲選択でもドロワーを開く (開いていれば選択範囲に合わせて描き直す) */ if(col && col.kind==='refcombo' && selA.c===c) pendingDrawer={kind:'adRange', u:cell.dataset.u, f:cell.dataset.f}; /* 同じ列を縦に選んだときだけ (矩形選択は従来どおり開かない) */ } else { selA={r,c}; selB={r,c}; dragging='cell'; if(col && col.kind==='refcombo') pendingDrawer={kind:'ad', u:cell.dataset.u, f:cell.dataset.f}; else if(col && (col.kind==='tgpick'||col.kind==='refpick') && cell.dataset.gf) pendingDrawer={kind:'tg', gu:cell.dataset.gu, gf:cell.dataset.gf}; else if(col && col.kind==='optpick' && cell.dataset.f) pendingDrawer={kind:'opt', u:cell.dataset.u, f:cell.dataset.f}; }
|
|
6678
7297
|
applySelection();
|
|
6679
7298
|
});
|
|
6680
7299
|
sheetHostEl.addEventListener('mouseover',e=>{
|
|
@@ -6698,7 +7317,7 @@ document.getElementById('drawerBackdrop').addEventListener('click',closeDrawer);
|
|
|
6698
7317
|
document.getElementById('drawerSearch').addEventListener('input',renderDrawer);
|
|
6699
7318
|
document.getElementById('drawerList').addEventListener('click',e=>{
|
|
6700
7319
|
/* サムネのクリックは拡大。行の選択には落とさない (見たいだけで選ぶとは限らない) */
|
|
6701
|
-
const th=e.target.closest('.ddthumb');
|
|
7320
|
+
const th=e.target.closest('.ddthumb[data-zoom]'); // ローカル素材のサムネ (data-localthumb) は拡大しない
|
|
6702
7321
|
if(th){ e.stopPropagation(); openThumbZoom(th.dataset.zoom); return; }
|
|
6703
7322
|
const row=e.target.closest('.ddrow'); if(!row) return; if(!drawerTarget) return;
|
|
6704
7323
|
if(drawerTarget.type==='tg'){ drawerTgToggle(row.dataset.tgval); }
|
|
@@ -6707,21 +7326,37 @@ document.getElementById('drawerList').addEventListener('click',e=>{
|
|
|
6707
7326
|
else if(drawerTarget.type==='agref'){ if(row.dataset.agid) addExistingAdGroup(row.dataset.agid); }
|
|
6708
7327
|
else if(drawerTarget.type==='cppick'){ if(row.dataset.cppick){ if(drawerTarget.mode==='addNew') addNewAdGroupToCampaign(row.dataset.cppick); else moveGroupToCampaign(drawerTarget.gUid, row.dataset.cppick); } }
|
|
6709
7328
|
else if(drawerTarget.type==='agpick'){ if(row.dataset.agpick) moveAdToGroup(drawerTarget.adUid, row.dataset.agpick); }
|
|
7329
|
+
/* 「+ 新規登録」。保存フォルダ → 素材 の2ステップをモーダルで案内する */
|
|
7330
|
+
else if(row.dataset.newmedia){ openNewMediaWizard(); }
|
|
7331
|
+
else if(row.dataset.seq){ applySequentialParamCodes(); renderDrawer(); }
|
|
6710
7332
|
else { drawerPick(row.dataset.no); }
|
|
6711
7333
|
});
|
|
6712
7334
|
/* 単体クリック/複数ドラッグ選択どちらでも開く(選択範囲全セルに一括適用) */
|
|
6713
|
-
document.addEventListener('mouseup',()=>{ if(pendingDrawer){ if(pendingDrawer.kind==='ad') openDrawerFor(pendingDrawer.u,pendingDrawer.f); else if(pendingDrawer.kind==='opt') openOptDrawer(pendingDrawer.u,pendingDrawer.f); else openTargetingDrawer(pendingDrawer.gu,pendingDrawer.gf); } pendingDrawer=null; deferredCollapse=null; if(dragging){ dragging=false; document.body.classList.remove('noselect'); } });
|
|
7335
|
+
document.addEventListener('mouseup',()=>{ if(pendingDrawer){ if(pendingDrawer.kind==='adRange'){ if(document.getElementById('refDrawer').classList.contains('open') && drawerTarget && drawerTarget.f===pendingDrawer.f) renderDrawer(); else openDrawerFor(pendingDrawer.u,pendingDrawer.f); } else if(pendingDrawer.kind==='ad') openDrawerFor(pendingDrawer.u,pendingDrawer.f); else if(pendingDrawer.kind==='opt') openOptDrawer(pendingDrawer.u,pendingDrawer.f); else openTargetingDrawer(pendingDrawer.gu,pendingDrawer.gf); } pendingDrawer=null; deferredCollapse=null; if(dragging){ dragging=false; document.body.classList.remove('noselect'); } });
|
|
6714
7336
|
|
|
6715
7337
|
/* ---- コピー / 貼付 ---- */
|
|
7338
|
+
/* シートの自前コピペ (セル範囲 → TSV) を使う場面か。
|
|
7339
|
+
モーダル・サイドバー・チップの入力欄や、シートの外の文字を選んでいるときはブラウザ標準に任せる。
|
|
7340
|
+
以前はシート表示中なら常に横取りしていたため、モーダルのパスやエラー文をコピーできず、
|
|
7341
|
+
モーダルの入力欄にも貼れなかった (セル範囲が選択されたままだと貼付がシートへ流れた)。 */
|
|
7342
|
+
function gridClipboardActive(){
|
|
7343
|
+
if(activeView!=='sheet'||planMode!=='sheet') return false;
|
|
7344
|
+
const mr=document.getElementById('modalRoot'); if(mr && mr.children.length) return false;
|
|
7345
|
+
const ae=document.activeElement;
|
|
7346
|
+
if(ae && ae.matches && ae.matches('input,textarea,select,[contenteditable="true"]') && !ae.classList.contains('cell-in')) return false;
|
|
7347
|
+
const sel=window.getSelection && window.getSelection();
|
|
7348
|
+
if(sel && !sel.isCollapsed && sel.anchorNode){ const host=document.getElementById('sheetHost'); if(!host || !host.contains(sel.anchorNode)) return false; }
|
|
7349
|
+
return true;
|
|
7350
|
+
}
|
|
6716
7351
|
document.addEventListener('copy',e=>{
|
|
6717
|
-
if(
|
|
7352
|
+
if(!gridClipboardActive()) return;
|
|
6718
7353
|
const rg=selRange();
|
|
6719
7354
|
if(rg){ const t=copySelText(); if(t!=null){ e.clipboardData.setData('text/plain',t); e.preventDefault(); } return; } // セル選択→常に自前TSV(ラベル/ボタンのDOMテキスト混入を防ぐ)
|
|
6720
7355
|
const ae=document.activeElement; // セル選択なし: 単一inputの部分テキスト選択のみnative許可
|
|
6721
7356
|
if(ae&&ae.classList&&ae.classList.contains('cell-in')&&ae.selectionStart!=null&&ae.selectionStart!==ae.selectionEnd) return;
|
|
6722
7357
|
e.preventDefault();
|
|
6723
7358
|
});
|
|
6724
|
-
document.addEventListener('paste',e=>{ if(
|
|
7359
|
+
document.addEventListener('paste',e=>{ if(!gridClipboardActive()) return; if(!selRange()) return; const txt=(e.clipboardData||window.clipboardData).getData('text'); if(txt==null) return; e.preventDefault(); pasteGrid(txt); });
|
|
6725
7360
|
document.addEventListener('keydown',e=>{
|
|
6726
7361
|
/* 自動で消えないトーストはどのビューからでも出る(書き出しボタンは常設ヘッダー)。
|
|
6727
7362
|
ビューのガードより手前で閉じないと、title の「閉じる (Esc)」と食い違う */
|