832 lines
30 KiB
HTML
832 lines
30 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ja">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>VRC Meta Viewer</title>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Outfit:wght@300;400;600;700&display=swap');
|
||
|
||
:root {
|
||
--bg: #0a0c14;
|
||
--panel: #0f1220;
|
||
--border: #1e2540;
|
||
--accent: #00e5ff;
|
||
--accent2: #7c3aed;
|
||
--accent3: #f472b6;
|
||
--text: #c9d4ef;
|
||
--muted: #4a5580;
|
||
--success: #10d98a;
|
||
--mono: 'Share Tech Mono', monospace;
|
||
--sans: 'Outfit', sans-serif;
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
body {
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: var(--sans);
|
||
min-height: 100vh;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
/* grid bg */
|
||
body::before {
|
||
content: '';
|
||
position: fixed;
|
||
inset: 0;
|
||
background-image:
|
||
linear-gradient(rgba(0,229,255,.03) 1px, transparent 1px),
|
||
linear-gradient(90deg, rgba(0,229,255,.03) 1px, transparent 1px);
|
||
background-size: 40px 40px;
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
|
||
.wrap {
|
||
position: relative;
|
||
z-index: 1;
|
||
max-width: 960px;
|
||
margin: 0 auto;
|
||
padding: 48px 24px;
|
||
}
|
||
|
||
/* header */
|
||
header {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 16px;
|
||
margin-bottom: 40px;
|
||
}
|
||
.logo {
|
||
font-family: var(--mono);
|
||
font-size: 11px;
|
||
letter-spacing: .2em;
|
||
color: var(--accent);
|
||
text-transform: uppercase;
|
||
opacity: .7;
|
||
}
|
||
h1 {
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
letter-spacing: -.02em;
|
||
color: #fff;
|
||
}
|
||
h1 span { color: var(--accent); }
|
||
|
||
/* drop zone */
|
||
#dropzone {
|
||
border: 2px dashed var(--border);
|
||
border-radius: 16px;
|
||
padding: 64px 32px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: border-color .2s, background .2s;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
#dropzone::before {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
background: radial-gradient(ellipse at 50% 120%, rgba(0,229,255,.06) 0%, transparent 70%);
|
||
pointer-events: none;
|
||
}
|
||
#dropzone:hover, #dropzone.over {
|
||
border-color: var(--accent);
|
||
background: rgba(0,229,255,.03);
|
||
}
|
||
#dropzone .icon {
|
||
font-size: 48px;
|
||
margin-bottom: 16px;
|
||
opacity: .5;
|
||
}
|
||
#dropzone p {
|
||
color: var(--muted);
|
||
font-size: 15px;
|
||
}
|
||
#dropzone p strong {
|
||
color: var(--accent);
|
||
font-weight: 600;
|
||
}
|
||
#fileInput { display: none; }
|
||
|
||
/* results */
|
||
#results { display: none; margin-top: 40px; }
|
||
|
||
.preview-wrap {
|
||
display: grid;
|
||
grid-template-columns: 280px 1fr;
|
||
gap: 24px;
|
||
margin-bottom: 32px;
|
||
align-items: start;
|
||
}
|
||
@media (max-width: 640px) {
|
||
.preview-wrap { grid-template-columns: 1fr; }
|
||
}
|
||
|
||
.preview-img {
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
border: 1px solid var(--border);
|
||
position: relative;
|
||
}
|
||
.preview-img img {
|
||
width: 100%;
|
||
display: block;
|
||
max-height: 280px;
|
||
object-fit: cover;
|
||
}
|
||
.preview-badge {
|
||
position: absolute;
|
||
bottom: 10px;
|
||
left: 10px;
|
||
background: rgba(10,12,20,.85);
|
||
backdrop-filter: blur(8px);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 4px 10px;
|
||
font-family: var(--mono);
|
||
font-size: 11px;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.file-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
.file-name {
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
color: #fff;
|
||
word-break: break-all;
|
||
}
|
||
.file-stats {
|
||
display: flex;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.stat-chip {
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 4px 12px;
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
.stat-chip b { color: var(--text); }
|
||
|
||
/* section */
|
||
.section {
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
margin-bottom: 16px;
|
||
overflow: hidden;
|
||
}
|
||
.section-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 14px 20px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
border-bottom: 1px solid transparent;
|
||
transition: background .15s;
|
||
}
|
||
.section-head:hover { background: rgba(255,255,255,.02); }
|
||
.section-head.open { border-bottom-color: var(--border); }
|
||
.section-tag {
|
||
font-family: var(--mono);
|
||
font-size: 10px;
|
||
letter-spacing: .15em;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
text-transform: uppercase;
|
||
}
|
||
.tag-basic { background: rgba(0,229,255,.15); color: var(--accent); }
|
||
.tag-exif { background: rgba(124,58,237,.2); color: #a78bfa; }
|
||
.tag-gps { background: rgba(16,217,138,.15); color: var(--success); }
|
||
.tag_vrc { background: rgba(244,114,182,.15);color: var(--accent3); }
|
||
.tag-iptc { background: rgba(251,191,36,.15); color: #fbbf24; }
|
||
.tag-png { background: rgba(99,102,241,.2); color: #818cf8; }
|
||
.tag-raw { background: rgba(75,85,99,.3); color: #9ca3af; }
|
||
.tag-xmp { background: rgba(234,179,8,.15); color: #facc15; }
|
||
|
||
.section-title {
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
color: #fff;
|
||
flex: 1;
|
||
}
|
||
.section-count {
|
||
font-family: var(--mono);
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
}
|
||
.chevron {
|
||
color: var(--muted);
|
||
transition: transform .2s;
|
||
font-size: 12px;
|
||
}
|
||
.chevron.open { transform: rotate(90deg); }
|
||
|
||
.section-body {
|
||
display: none;
|
||
padding: 0;
|
||
}
|
||
.section-body.open { display: block; }
|
||
|
||
table { width: 100%; border-collapse: collapse; }
|
||
tr { border-bottom: 1px solid rgba(30,37,64,.5); }
|
||
tr:last-child { border-bottom: none; }
|
||
tr:hover td { background: rgba(255,255,255,.015); }
|
||
td {
|
||
padding: 10px 20px;
|
||
font-size: 13px;
|
||
vertical-align: top;
|
||
line-height: 1.5;
|
||
}
|
||
td:first-child {
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
width: 240px;
|
||
white-space: nowrap;
|
||
}
|
||
td:last-child {
|
||
color: var(--text);
|
||
word-break: break-all;
|
||
}
|
||
.highlight-val {
|
||
color: var(--accent);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* VRC section special */
|
||
.vrc-banner {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 16px 20px;
|
||
background: linear-gradient(90deg, rgba(244,114,182,.08), transparent);
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.vrc-dot {
|
||
width: 8px; height: 8px;
|
||
background: var(--accent3);
|
||
border-radius: 50%;
|
||
animation: pulse 2s infinite;
|
||
}
|
||
@keyframes pulse {
|
||
0%,100% { box-shadow: 0 0 0 0 rgba(244,114,182,.4); }
|
||
50% { box-shadow: 0 0 0 6px rgba(244,114,182,0); }
|
||
}
|
||
.vrc-label { font-size: 13px; color: var(--accent3); font-weight: 600; }
|
||
|
||
/* no meta */
|
||
.empty-state {
|
||
padding: 24px 20px;
|
||
text-align: center;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
/* reset btn */
|
||
.btn-reset {
|
||
margin-top: 32px;
|
||
background: transparent;
|
||
border: 1px solid var(--border);
|
||
color: var(--muted);
|
||
font-family: var(--sans);
|
||
font-size: 13px;
|
||
padding: 10px 24px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: border-color .2s, color .2s;
|
||
}
|
||
.btn-reset:hover { border-color: var(--accent); color: var(--accent); }
|
||
|
||
/* copy */
|
||
.copy-all-wrap { text-align: right; padding: 0 20px 16px; }
|
||
.btn-copy {
|
||
background: transparent;
|
||
border: 1px solid var(--border);
|
||
color: var(--muted);
|
||
font-family: var(--mono);
|
||
font-size: 11px;
|
||
letter-spacing: .1em;
|
||
padding: 5px 14px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all .2s;
|
||
}
|
||
.btn-copy:hover { border-color: var(--accent); color: var(--accent); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<header>
|
||
<span class="logo">// tool</span>
|
||
<h1>VRC <span>Meta</span> Viewer</h1>
|
||
</header>
|
||
|
||
<div id="dropzone" onclick="document.getElementById('fileInput').click()">
|
||
<input type="file" id="fileInput" accept="image/*">
|
||
<div class="icon">🖼️</div>
|
||
<p><strong>クリックして画像を選択</strong> または ドラッグ&ドロップ</p>
|
||
<p style="margin-top:8px;font-size:12px;">PNG / JPEG / WebP / TIFF など対応</p>
|
||
</div>
|
||
|
||
<div id="results"></div>
|
||
</div>
|
||
|
||
<script>
|
||
// ─── EXIF tag dictionaries ────────────────────────────────────────────
|
||
const EXIF_TAGS = {
|
||
0x010E:'ImageDescription',0x010F:'Make',0x0110:'Model',0x0112:'Orientation',
|
||
0x011A:'XResolution',0x011B:'YResolution',0x0128:'ResolutionUnit',
|
||
0x0131:'Software',0x0132:'DateTime',0x013B:'Artist',
|
||
0x013E:'WhitePoint',0x013F:'PrimaryChromaticities',
|
||
0x0211:'YCbCrCoefficients',0x0213:'YCbCrPositioning',
|
||
0x0214:'ReferenceBlackWhite',0x8298:'Copyright',
|
||
0x8769:'ExifIFD',0x8825:'GPSIFD',
|
||
0x9000:'ExifVersion',0x9003:'DateTimeOriginal',0x9004:'DateTimeDigitized',
|
||
0x9101:'ComponentsConfiguration',0x9102:'CompressedBitsPerPixel',
|
||
0x9201:'ShutterSpeedValue',0x9202:'ApertureValue',0x9203:'BrightnessValue',
|
||
0x9204:'ExposureBiasValue',0x9205:'MaxApertureValue',0x9206:'SubjectDistance',
|
||
0x9207:'MeteringMode',0x9208:'LightSource',0x9209:'Flash',
|
||
0x920A:'FocalLength',0x9214:'SubjectArea',
|
||
0xA000:'FlashpixVersion',0xA001:'ColorSpace',0xA002:'PixelXDimension',
|
||
0xA003:'PixelYDimension',0xA005:'InteroperabilityIFD',
|
||
0xA20E:'FocalPlaneXResolution',0xA20F:'FocalPlaneYResolution',
|
||
0xA210:'FocalPlaneResolutionUnit',0xA214:'SubjectLocation',
|
||
0xA215:'ExposureIndex',0xA217:'SensingMethod',
|
||
0xA300:'FileSource',0xA301:'SceneType',0xA302:'CFAPattern',
|
||
0xA401:'CustomRendered',0xA402:'ExposureMode',0xA403:'WhiteBalance',
|
||
0xA404:'DigitalZoomRatio',0xA405:'FocalLengthIn35mmFilm',
|
||
0xA406:'SceneCaptureType',0xA407:'GainControl',0xA408:'Contrast',
|
||
0xA409:'Saturation',0xA40A:'Sharpness',0xA40B:'DeviceSettingDescription',
|
||
0xA40C:'SubjectDistanceRange',0xA420:'ImageUniqueID',
|
||
0xA430:'CameraOwnerName',0xA431:'BodySerialNumber',
|
||
0xA432:'LensSpecification',0xA433:'LensMake',0xA434:'LensModel',
|
||
0x0100:'ImageWidth',0x0101:'ImageLength',0x0102:'BitsPerSample',
|
||
0x0103:'Compression',0x0106:'PhotometricInterpretation',
|
||
0x0111:'StripOffsets',0x0115:'SamplesPerPixel',0x0116:'RowsPerStrip',
|
||
0x0117:'StripByteCounts',0x011C:'PlanarConfiguration',
|
||
};
|
||
const GPS_TAGS = {
|
||
0:'GPSVersionID',1:'GPSLatitudeRef',2:'GPSLatitude',3:'GPSLongitudeRef',
|
||
4:'GPSLongitude',5:'GPSAltitudeRef',6:'GPSAltitude',7:'GPSTimeStamp',
|
||
8:'GPSSatellites',9:'GPSStatus',10:'GPSMeasureMode',11:'GPSDOP',
|
||
12:'GPSSpeedRef',13:'GPSSpeed',14:'GPSTrackRef',15:'GPSTrack',
|
||
16:'GPSImgDirectionRef',17:'GPSImgDirection',18:'GPSMapDatum',
|
||
27:'GPSProcessingMethod',29:'GPSDateStamp',
|
||
};
|
||
const ORIENTATION_MAP = {1:'正常',2:'水平反転',3:'180°回転',4:'垂直反転',5:'90°右回転+水平反転',6:'90°右回転',7:'90°左回転+水平反転',8:'90°左回転'};
|
||
const METERING_MAP = {0:'不明',1:'平均',2:'中央重点',3:'スポット',4:'マルチスポット',5:'マルチパターン',6:'部分'};
|
||
const FLASH_MAP = {0:'発光なし',1:'発光',5:'発光(ストロボなし)',7:'発光(ストロボあり)',9:'強制発光',13:'強制発光(ストロボなし)',15:'強制発光(ストロボあり)',16:'発光禁止',24:'自動(発光なし)',25:'自動(発光)',29:'自動(発光、ストロボなし)',31:'自動(発光、ストロボあり)',32:'フラッシュなし',65:'赤目軽減',69:'赤目軽減(ストロボなし)',71:'赤目軽減(ストロボあり)',73:'強制発光+赤目軽減',77:'強制発光+赤目軽減(ストロボなし)',79:'強制発光+赤目軽減(ストロボあり)',89:'自動+赤目軽減',93:'自動+赤目軽減(ストロボなし)',95:'自動+赤目軽減(ストロボあり)'};
|
||
const COLORSPACE_MAP= {1:'sRGB',65535:'未キャリブレーション'};
|
||
const RESOLUTION_UNIT_MAP={1:'なし',2:'インチ',3:'センチメートル'};
|
||
const EXPOSURE_MODE_MAP={0:'自動',1:'マニュアル',2:'自動ブラケット'};
|
||
const WHITE_BALANCE_MAP={0:'自動',1:'マニュアル'};
|
||
const SCENE_CAPTURE_MAP={0:'標準',1:'風景',2:'ポートレート',3:'夜景'};
|
||
const LIGHT_SOURCE_MAP={0:'不明',1:'太陽光',2:'蛍光灯',3:'タングステン',4:'フラッシュ',9:'晴天',10:'曇天',11:'日陰',12:'昼光色蛍光灯',13:'昼白色蛍光灯',14:'白色蛍光灯',15:'白熱電球',255:'その他'};
|
||
const SENSING_METHOD_MAP={1:'未定義',2:'1チップカラーエリアセンサ',3:'2チップカラーエリアセンサ',4:'3チップカラーエリアセンサ',5:'カラーシーケンシャルエリアセンサ',7:'トリリニアセンサ',8:'カラーシーケンシャルリニアセンサ'};
|
||
|
||
// ─── VRC-specific EXIF & PNG keyword detection ───────────────────────
|
||
const VRC_KEYS = ['vrc','vrchat','world','worldid','instanceid','author','photographerDisplayName'];
|
||
|
||
function isVRCKey(k) {
|
||
const l = k.toLowerCase();
|
||
return VRC_KEYS.some(v => l.includes(v));
|
||
}
|
||
|
||
// ─── Drag & Drop ──────────────────────────────────────────────────────
|
||
const dz = document.getElementById('dropzone');
|
||
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('over'); });
|
||
dz.addEventListener('dragleave', () => dz.classList.remove('over'));
|
||
dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('over'); handleFile(e.dataTransfer.files[0]); });
|
||
document.getElementById('fileInput').addEventListener('change', e => handleFile(e.target.files[0]));
|
||
|
||
function handleFile(file) {
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = e => parse(file, e.target.result);
|
||
reader.readAsArrayBuffer(file);
|
||
}
|
||
|
||
// ─── Main parse ───────────────────────────────────────────────────────
|
||
function parse(file, buf) {
|
||
const u8 = new Uint8Array(buf);
|
||
const result = {
|
||
basic: {
|
||
fileName: file.name,
|
||
fileSize: formatBytes(file.size),
|
||
fileType: file.type || '不明',
|
||
lastModified: new Date(file.lastModified).toLocaleString('ja-JP'),
|
||
},
|
||
exif: null,
|
||
gps: null,
|
||
iptc: null,
|
||
png: null,
|
||
vrc: [],
|
||
raw: [],
|
||
preview: null,
|
||
};
|
||
|
||
// preview
|
||
result.preview = URL.createObjectURL(file);
|
||
|
||
const sig2 = (u8[0] << 8) | u8[1];
|
||
const isPNG = u8[0]===137 && u8[1]===80 && u8[2]===78 && u8[3]===71;
|
||
|
||
if (sig2 === 0xFFD8) {
|
||
parseJPEG(u8, result);
|
||
} else if (isPNG) {
|
||
parsePNG(u8, result);
|
||
}
|
||
|
||
render(result, file);
|
||
}
|
||
|
||
// ─── JPEG parser ──────────────────────────────────────────────────────
|
||
function parseJPEG(u8, result) {
|
||
let off = 2;
|
||
while (off < u8.length - 4) {
|
||
if (u8[off] !== 0xFF) break;
|
||
const marker = (u8[off] << 8) | u8[off+1];
|
||
const len = (u8[off+2] << 8) | u8[off+3];
|
||
if (marker === 0xFFE1) {
|
||
const seg = u8.slice(off+4, off+2+len);
|
||
if (seg[0]===69&&seg[1]===120&&seg[2]===105&&seg[3]===102) {
|
||
readEXIF(seg.slice(6), result);
|
||
}
|
||
// XMP packet: "http://ns.adobe.com/xap/1.0/\0"
|
||
const xmpNs = [104,116,116,112,58,47,47,110,115,46,97,100,111,98,101,46,99,111,109,47,120,97,112,47,49,46,48,47,0];
|
||
if (xmpNs.every((b,i)=>seg[i]===b)) {
|
||
const xmpStr = new TextDecoder().decode(seg.slice(xmpNs.length));
|
||
readXMP(xmpStr, result);
|
||
}
|
||
}
|
||
if (marker === 0xFFED) {
|
||
const seg = u8.slice(off+4, off+2+len);
|
||
readIPTC(seg, result);
|
||
}
|
||
off += 2 + len;
|
||
}
|
||
}
|
||
|
||
// ─── EXIF reader ──────────────────────────────────────────────────────
|
||
function readEXIF(data, result) {
|
||
const le = data[0]===0x49;
|
||
const r16 = o => le ? (data[o]|(data[o+1]<<8)) : ((data[o]<<8)|data[o+1]);
|
||
const r32 = o => le ? (data[o]|(data[o+1]<<8)|(data[o+2]<<16)|(data[o+3]<<24)) : ((data[o]<<24)|(data[o+1]<<16)|(data[o+2]<<8)|data[o+3]);
|
||
|
||
function readIFD(ifdOff, tagDict, target) {
|
||
if (ifdOff < 0 || ifdOff + 2 > data.length) return;
|
||
const count = r16(ifdOff);
|
||
for (let i = 0; i < count; i++) {
|
||
const off = ifdOff + 2 + i * 12;
|
||
const tag = r16(off);
|
||
const type = r16(off+2);
|
||
const num = r32(off+4);
|
||
const name = tagDict[tag] || `Tag_0x${tag.toString(16).padStart(4,'0')}`;
|
||
|
||
// sub-IFD
|
||
if (tag === 0x8769 || tag === 0x8825) {
|
||
const subOff = r32(off+8);
|
||
readIFD(subOff, tag===0x8825?GPS_TAGS:EXIF_TAGS, tag===0x8825?(result.gps=result.gps||{}):(result.exif=result.exif||{}));
|
||
continue;
|
||
}
|
||
|
||
const val = readValue(data, r16, r32, type, num, off+8, le);
|
||
target[name] = val;
|
||
result.raw.push({tag:`0x${tag.toString(16).padStart(4,'0')}`, name, value: String(val)});
|
||
if (isVRCKey(name) || isVRCKey(String(val))) {
|
||
result.vrc.push({key: name, value: String(val)});
|
||
}
|
||
}
|
||
}
|
||
|
||
result.exif = {};
|
||
result.gps = {};
|
||
const ifd0 = r32(4);
|
||
readIFD(ifd0, EXIF_TAGS, result.exif);
|
||
}
|
||
|
||
function readValue(data, r16, r32, type, count, valOff, le) {
|
||
const BYTE_SIZES = [0,1,1,2,4,8,1,1,2,4,8,4,8];
|
||
const totalBytes = (BYTE_SIZES[type]||1) * count;
|
||
let dataOff = valOff;
|
||
if (totalBytes > 4) dataOff = le ? (data[valOff]|(data[valOff+1]<<8)|(data[valOff+2]<<16)|(data[valOff+3]<<24)) : ((data[valOff]<<24)|(data[valOff+1]<<16)|(data[valOff+2]<<8)|data[valOff+3]);
|
||
|
||
if (type===2) { // ASCII
|
||
let s=''; for(let i=0;i<count-1;i++) s+=String.fromCharCode(data[dataOff+i]||0); return s;
|
||
}
|
||
if (type===5||type===10) { // Rational
|
||
const vals=[];
|
||
for(let i=0;i<Math.min(count,4);i++){
|
||
const n=le?(data[dataOff+i*8]|(data[dataOff+i*8+1]<<8)|(data[dataOff+i*8+2]<<16)|(data[dataOff+i*8+3]<<24)):((data[dataOff+i*8]<<24)|(data[dataOff+i*8+1]<<16)|(data[dataOff+i*8+2]<<8)|data[dataOff+i*8+3]);
|
||
const d=le?(data[dataOff+i*8+4]|(data[dataOff+i*8+5]<<8)|(data[dataOff+i*8+6]<<16)|(data[dataOff+i*8+7]<<24)):((data[dataOff+i*8+4]<<24)|(data[dataOff+i*8+5]<<16)|(data[dataOff+i*8+6]<<8)|data[dataOff+i*8+7]);
|
||
vals.push(d===0?n:(n/d).toFixed(4));
|
||
}
|
||
return vals.join(', ');
|
||
}
|
||
if (type===3) { // SHORT
|
||
const vals=[];
|
||
for(let i=0;i<Math.min(count,8);i++) vals.push(r16(dataOff+i*2));
|
||
return count===1?vals[0]:vals.join(', ');
|
||
}
|
||
if (type===4||type===9) { // LONG
|
||
return r32(dataOff);
|
||
}
|
||
if (type===1||type===7) { // BYTE/UNDEFINED
|
||
const vals=[];
|
||
for(let i=0;i<Math.min(count,16);i++) vals.push(data[dataOff+i]);
|
||
if(count>16) return '['+vals.join(',')+' ...]';
|
||
return '['+vals.join(',')+']';
|
||
}
|
||
return `[type=${type},count=${count}]`;
|
||
}
|
||
|
||
// ─── IPTC reader ──────────────────────────────────────────────────────
|
||
const IPTC_TAGS = {5:'ObjectName',7:'EditStatus',10:'Urgency',15:'Category',20:'SupplementalCategories',22:'FixtureIdentifier',25:'Keywords',26:'ContentLocationCode',27:'ContentLocationName',30:'ReleaseDate',35:'ReleaseTime',37:'ExpirationDate',38:'ExpirationTime',40:'SpecialInstructions',55:'DateCreated',60:'TimeCreated',62:'DigitalCreationDate',63:'DigitalCreationTime',65:'OriginatingProgram',70:'ProgramVersion',80:'ByLine',85:'ByLineTitle',90:'City',92:'SubLocation',95:'Province',100:'CountryCode',101:'Country',103:'OriginalTransmissionReference',105:'Headline',110:'Credit',115:'Source',116:'Copyright',118:'Contact',120:'Caption',122:'Writer'};
|
||
|
||
function readIPTC(seg, result) {
|
||
result.iptc = {};
|
||
let i=0;
|
||
while(i<seg.length-4){
|
||
if(seg[i]===0x1C&&seg[i+1]===0x02){
|
||
const tag=seg[i+2];
|
||
const len=(seg[i+3]<<8)|seg[i+4];
|
||
const val=Array.from(seg.slice(i+5,i+5+len)).map(c=>String.fromCharCode(c)).join('');
|
||
const name=IPTC_TAGS[tag]||`IPTC_${tag}`;
|
||
result.iptc[name]=val;
|
||
if(isVRCKey(name)||isVRCKey(val)) result.vrc.push({key:name,value:val});
|
||
i+=5+len;
|
||
} else i++;
|
||
}
|
||
}
|
||
|
||
// ─── XMP parser ───────────────────────────────────────────────────────
|
||
function readXMP(xmlStr, result) {
|
||
result.xmp = {};
|
||
// Use DOMParser to parse the XMP XML
|
||
let doc;
|
||
try {
|
||
doc = new DOMParser().parseFromString(xmlStr, 'text/xml');
|
||
} catch(e) { return; }
|
||
|
||
// Friendly label map for known namespaces
|
||
const NS_LABELS = {
|
||
'http://ns.adobe.com/xap/1.0/': 'xmp',
|
||
'http://ns.vrchat.com/vrc/1.0/': 'vrc',
|
||
'http://purl.org/dc/elements/1.1/': 'dc',
|
||
'http://ns.adobe.com/tiff/1.0/': 'tiff',
|
||
'http://ns.adobe.com/exif/1.0/': 'exif',
|
||
'http://ns.adobe.com/photoshop/1.0/': 'photoshop',
|
||
'http://ns.adobe.com/xap/1.0/rights/': 'xmpRights',
|
||
};
|
||
|
||
function nsLabel(uri) {
|
||
return NS_LABELS[uri] || uri.split('/').filter(Boolean).pop() || uri;
|
||
}
|
||
|
||
function extractText(el) {
|
||
// rdf:Alt / rdf:Seq / rdf:Bag -> join li items
|
||
const li = el.querySelectorAll('li');
|
||
if (li.length) return Array.from(li).map(l=>l.textContent.trim()).filter(Boolean).join(', ');
|
||
return el.textContent.trim();
|
||
}
|
||
|
||
const descs = doc.querySelectorAll('Description');
|
||
descs.forEach(desc => {
|
||
Array.from(desc.children).forEach(child => {
|
||
const ns = nsLabel(child.namespaceURI || '');
|
||
const local = child.localName;
|
||
const key = ns ? `${ns}:${local}` : local;
|
||
const val = extractText(child);
|
||
if (!val) return;
|
||
result.xmp[key] = val;
|
||
result.raw.push({tag:'XMP', name:key, value:val});
|
||
if (isVRCKey(key) || isVRCKey(val)) {
|
||
// avoid duplicates
|
||
if (!result.vrc.find(v=>v.key===key)) {
|
||
result.vrc.push({key, value: val});
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ─── PNG parser ───────────────────────────────────────────────────────
|
||
function parsePNG(u8, result) {
|
||
result.png = {};
|
||
let off=8;
|
||
while(off<u8.length-8){
|
||
const len=(u8[off]<<24)|(u8[off+1]<<16)|(u8[off+2]<<8)|u8[off+3];
|
||
const type=String.fromCharCode(u8[off+4],u8[off+5],u8[off+6],u8[off+7]);
|
||
if(type==='IHDR'){
|
||
result.png['Width'] =((u8[off+8]<<24)|(u8[off+9]<<16)|(u8[off+10]<<8)|u8[off+11])+'px';
|
||
result.png['Height']=((u8[off+12]<<24)|(u8[off+13]<<16)|(u8[off+14]<<8)|u8[off+15])+'px';
|
||
result.png['BitDepth']=u8[off+16];
|
||
const colorTypeMap={0:'グレースケール',2:'RGB',3:'インデックス',4:'グレースケール+α',6:'RGBA'};
|
||
result.png['ColorType']=colorTypeMap[u8[off+17]]||u8[off+17];
|
||
result.png['Interlace']=u8[off+20]?'あり(Adam7)':'なし';
|
||
}
|
||
if(type==='tEXt'||type==='iTXt'||type==='zTXt'){
|
||
const chunk=u8.slice(off+8,off+8+len);
|
||
let nullIdx=chunk.indexOf(0);
|
||
if(nullIdx>0){
|
||
const key=String.fromCharCode(...chunk.slice(0,nullIdx));
|
||
let valStart=nullIdx+1;
|
||
if(type==='iTXt') valStart=nullIdx+4; // skip flags/encoding
|
||
const val=new TextDecoder().decode(chunk.slice(valStart));
|
||
result.png[key]=val;
|
||
if(isVRCKey(key)||isVRCKey(val)) result.vrc.push({key,value:val});
|
||
result.raw.push({tag:type,name:key,value:val});
|
||
// XMP embedded in PNG tEXt/iTXt with key "XML:com.adobe.xmp"
|
||
if(key==='XML:com.adobe.xmp'||key==='xmp') readXMP(val, result);
|
||
}
|
||
}
|
||
if(type==='eXIf'){
|
||
readEXIF(u8.slice(off+8,off+8+len), result);
|
||
}
|
||
if(type==='IEND') break;
|
||
off+=12+len;
|
||
}
|
||
}
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────
|
||
function formatBytes(b){const u=['B','KB','MB','GB'];let i=0;while(b>=1024&&i<3){b/=1024;i++;}return b.toFixed(1)+' '+u[i];}
|
||
|
||
function humanize(key, val) {
|
||
const n=Number(val);
|
||
if(key==='Orientation' && ORIENTATION_MAP[n]) return ORIENTATION_MAP[n];
|
||
if(key==='MeteringMode'&& METERING_MAP[n]) return METERING_MAP[n];
|
||
if(key==='Flash' && FLASH_MAP[n]) return FLASH_MAP[n];
|
||
if(key==='ColorSpace' && COLORSPACE_MAP[n])return COLORSPACE_MAP[n];
|
||
if(key==='ResolutionUnit'&&RESOLUTION_UNIT_MAP[n])return RESOLUTION_UNIT_MAP[n];
|
||
if(key==='ExposureMode'&&EXPOSURE_MODE_MAP[n])return EXPOSURE_MODE_MAP[n];
|
||
if(key==='WhiteBalance'&&WHITE_BALANCE_MAP[n])return WHITE_BALANCE_MAP[n];
|
||
if(key==='SceneCaptureType'&&SCENE_CAPTURE_MAP[n])return SCENE_CAPTURE_MAP[n];
|
||
if(key==='LightSource' &&LIGHT_SOURCE_MAP[n])return LIGHT_SOURCE_MAP[n];
|
||
if(key==='SensingMethod'&&SENSING_METHOD_MAP[n])return SENSING_METHOD_MAP[n];
|
||
return val;
|
||
}
|
||
|
||
// ─── Render ───────────────────────────────────────────────────────────
|
||
function render(result, file) {
|
||
const el = document.getElementById('results');
|
||
el.style.display='block';
|
||
dz.style.display='none';
|
||
|
||
let allText = '';
|
||
const sections=[];
|
||
|
||
// Build allText helper
|
||
function addToAll(label, rows){
|
||
allText+=`\n=== ${label} ===\n`;
|
||
rows.forEach(r=>allText+=`${r[0]}: ${r[1]}\n`);
|
||
}
|
||
|
||
// — Basic info
|
||
const basicRows=[
|
||
['ファイル名',result.basic.fileName],
|
||
['ファイルサイズ',result.basic.fileSize],
|
||
['MIMEタイプ',result.basic.fileType],
|
||
['最終更新日時',result.basic.lastModified],
|
||
];
|
||
addToAll('基本情報',basicRows);
|
||
sections.push({tag:'tag-basic',label:'基本情報',tagLabel:'BASIC',rows:basicRows,isVRC:false});
|
||
|
||
// — VRC
|
||
if(result.vrc && result.vrc.length>0){
|
||
const vrcRows = result.vrc.map(v=>[v.key,v.value]);
|
||
addToAll('VRChat メタデータ',vrcRows);
|
||
sections.push({tag:'tag_vrc',label:'VRChat メタデータ',tagLabel:'VRC',rows:vrcRows,isVRC:true,open:true});
|
||
}
|
||
|
||
// — XMP
|
||
if(result.xmp && Object.keys(result.xmp).length>0){
|
||
const rows=Object.entries(result.xmp).map(([k,v])=>[k,v]);
|
||
addToAll('XMP',rows);
|
||
sections.push({tag:'tag-xmp',label:'XMP メタデータ',tagLabel:'XMP',rows,open:true});
|
||
}
|
||
|
||
// — EXIF
|
||
if(result.exif && Object.keys(result.exif).length>0){
|
||
const rows=Object.entries(result.exif).map(([k,v])=>[k, humanize(k,v)]);
|
||
addToAll('EXIF',rows);
|
||
sections.push({tag:'tag-exif',label:'EXIF データ',tagLabel:'EXIF',rows,open:true});
|
||
}
|
||
|
||
// — GPS
|
||
if(result.gps && Object.keys(result.gps).length>0){
|
||
const rows=Object.entries(result.gps).map(([k,v])=>[k,v]);
|
||
addToAll('GPS',rows);
|
||
sections.push({tag:'tag-gps',label:'GPS 情報',tagLabel:'GPS',rows,open:true});
|
||
}
|
||
|
||
// — PNG
|
||
if(result.png && Object.keys(result.png).length>0){
|
||
const rows=Object.entries(result.png).map(([k,v])=>[k,v]);
|
||
addToAll('PNG チャンク',rows);
|
||
sections.push({tag:'tag-png',label:'PNG チャンク',tagLabel:'PNG',rows,open:false});
|
||
}
|
||
|
||
// — IPTC
|
||
if(result.iptc && Object.keys(result.iptc).length>0){
|
||
const rows=Object.entries(result.iptc).map(([k,v])=>[k,v]);
|
||
addToAll('IPTC',rows);
|
||
sections.push({tag:'tag-iptc',label:'IPTC データ',tagLabel:'IPTC',rows,open:false});
|
||
}
|
||
|
||
// — Raw (all tags)
|
||
if(result.raw && result.raw.length>0){
|
||
const rows=result.raw.map(r=>[`${r.tag} ${r.name}`,r.value]);
|
||
addToAll('全タグ(Raw)',rows);
|
||
sections.push({tag:'tag-raw',label:`全タグ (Raw) — ${result.raw.length} 件`,tagLabel:'RAW',rows,open:false});
|
||
}
|
||
|
||
// render HTML
|
||
let html=`
|
||
<div class="preview-wrap">
|
||
<div class="preview-img">
|
||
<img src="${result.preview}" alt="preview">
|
||
<div class="preview-badge">${result.basic.fileType}</div>
|
||
</div>
|
||
<div class="file-info">
|
||
<div class="file-name">${escHtml(result.basic.fileName)}</div>
|
||
<div class="file-stats">
|
||
<div class="stat-chip"><b>${result.basic.fileSize}</b></div>
|
||
<div class="stat-chip">更新: <b>${result.basic.lastModified}</b></div>
|
||
${result.vrc&&result.vrc.length>0?`<div class="stat-chip" style="border-color:rgba(244,114,182,.4);color:var(--accent3)">⚡ VRC メタデータあり</div>`:''}
|
||
</div>
|
||
<div class="copy-all-wrap" style="text-align:left;padding:0">
|
||
<button class="btn-copy" onclick="copyAll()">📋 全データをコピー</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
sections.forEach((sec,idx)=>{
|
||
const isOpen = sec.open !== false;
|
||
html+=`<div class="section">
|
||
<div class="section-head ${isOpen?'open':''}" onclick="toggleSec(${idx})">
|
||
<span class="section-tag ${sec.tag}">${sec.tagLabel}</span>
|
||
<span class="section-title">${sec.label}</span>
|
||
<span class="section-count">${sec.rows.length} 件</span>
|
||
<span class="chevron ${isOpen?'open':''}">▶</span>
|
||
</div>`;
|
||
if(sec.isVRC) html+=`<div class="vrc-banner"><div class="vrc-dot"></div><span class="vrc-label">VRChat 関連メタデータを検出</span></div>`;
|
||
html+=`<div class="section-body ${isOpen?'open':''}"><table>`;
|
||
sec.rows.forEach(([k,v])=>{
|
||
const isVRCRow=isVRCKey(k)||isVRCKey(String(v));
|
||
html+=`<tr><td>${escHtml(k)}</td><td class="${isVRCRow?'highlight-val':''}">${escHtml(String(v))}</td></tr>`;
|
||
});
|
||
html+=`</table></div></div>`;
|
||
});
|
||
|
||
if(sections.length<=1){
|
||
html+=`<div class="section"><div class="empty-state">⚠️ この画像にはメタデータが含まれていないか、対応フォーマット外です</div></div>`;
|
||
}
|
||
|
||
html+=`<button class="btn-reset" onclick="reset()">↩ 別の画像を選択</button>`;
|
||
el.innerHTML=html;
|
||
|
||
window._allText=allText;
|
||
}
|
||
|
||
window.toggleSec=function(idx){
|
||
const heads=document.querySelectorAll('.section-head');
|
||
const bodies=document.querySelectorAll('.section-body');
|
||
const chevrons=document.querySelectorAll('.chevron');
|
||
const h=heads[idx],b=bodies[idx],c=chevrons[idx];
|
||
const open=b.classList.toggle('open');
|
||
h.classList.toggle('open',open);
|
||
c.classList.toggle('open',open);
|
||
};
|
||
window.copyAll=function(){
|
||
navigator.clipboard.writeText(window._allText||'').then(()=>{
|
||
const btn=document.querySelector('.btn-copy');
|
||
const orig=btn.textContent;
|
||
btn.textContent='✅ コピーしました';
|
||
setTimeout(()=>btn.textContent=orig,2000);
|
||
});
|
||
};
|
||
window.reset=function(){
|
||
document.getElementById('results').style.display='none';
|
||
document.getElementById('results').innerHTML='';
|
||
document.getElementById('dropzone').style.display='block';
|
||
document.getElementById('fileInput').value='';
|
||
};
|
||
|
||
function escHtml(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||
</script>
|
||
</body>
|
||
</html>
|