param( [string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot), [string]$PackageDate = ([DateTimeOffset]::UtcNow.ToOffset([TimeSpan]::FromHours(8)).ToString('yyyy-MM-dd')), [string]$PackageName = '', [switch]$RefreshDescriptions ) $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.Drawing $ProjectRoot = [IO.Path]::GetFullPath($ProjectRoot) if (-not $PackageName) { $PackageName = '素材网上传包_' + $PackageDate } if ($PackageName -notmatch '^素材网上传包_[^/\\:]+$') { throw 'PackageName必须是素材网上传包_开头的单个目录名' } $dest = Join-Path $ProjectRoot "06_AI动漫制作/07_交付与发行/$PackageName" $existingNames=@{} if (Test-Path -LiteralPath $dest) { if (-not $RefreshDescriptions) { throw "目标已存在,为保留已有交付请另用PackageName:$dest" } $prior=Get-Content -LiteralPath "$dest/素材清单.json" -Raw | ConvertFrom-Json if ($prior.schema_version -ne 1 -or $prior.project -ne '炉火照彻诸神沉默的年代') {throw '不是本工具的交付包'} foreach($item in @($prior.items)+@(Get-Content -LiteralPath "$dest/外部参考清单.json" -Raw | ConvertFrom-Json)) {$existingNames[$item.sha256]=$item.filename} } $notes = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'image_catalog_notes.json') -Raw | ConvertFrom-Json -AsHashtable $episodeRoot = Join-Path $ProjectRoot '06_AI动漫制作/05_生产素材/正片/S01E01_H3_V01' $manifest = Get-Content -LiteralPath "$episodeRoot/source_manifest.json" -Raw | ConvertFrom-Json $shotMap = @{}; foreach ($shot in $manifest.shots) { $shotMap[$shot.id] = $shot } $sceneNames = @{1='前世租屋';2='化工厂交班与更衣';3='事故与救人';4='婴儿与泉堂外景';5='孤儿院照料婴儿';6='泉堂祝福与沉默';7='孤儿院日常';8='大公府出发';9='大圣堂会面';10='勇者装备交付';12='少年与送货'} $linked = @{} $indexPath = Join-Path $ProjectRoot '06_AI动漫制作/02_视觉圣经/角色设定/角色参考图索引.md' foreach ($m in [regex]::Matches([IO.File]::ReadAllText($indexPath),'\[([^\]]+)\]\(([^)]+\.png)\)')) { $target = [IO.Path]::GetFullPath((Join-Path (Split-Path $indexPath) $m.Groups[2].Value)) $linked[$target] = $m.Groups[1].Value } $reviewNotes = @{} $reviewDoc = Join-Path $ProjectRoot '06_AI动漫制作/02_视觉圣经/角色设定/2026-09-29_其余角色逐张生成_V08.md' foreach ($line in Get-Content -LiteralPath $reviewDoc) { if ($line -match '^\| \d+ \| ([^|]+) \| ([^|]+) \|') { $reviewNotes[$Matches[1].Trim()] = $Matches[2].Trim() } } Push-Location $ProjectRoot try { $paths = @(rg --files --hidden --no-ignore -g '*.png' -g '*.jpg' -g '*.jpeg' -g '*.webp' -g '*.gif' -g '*.bmp' -g '!.git/**' -g '!06_AI动漫制作/07_交付与发行/**') } finally { Pop-Location } $sources = [Collections.Generic.List[object]]::new() foreach ($rel in $paths) { $sources.Add([pscustomobject]@{Full=(Join-Path $ProjectRoot $rel);Rel=$rel.Replace('\','/');External=($rel -match '画风参考/|画风参考\\|用户提供');Origin='项目'}) } $desktop = 'C:\Users\zhang\Desktop\AI短剧\角色管理' if (Test-Path -LiteralPath $desktop) { foreach ($f in Get-ChildItem -LiteralPath $desktop -File -Recurse | Where-Object Extension -in @('.png','.jpg','.jpeg','.webp')) { $sources.Add([pscustomobject]@{Full=$f.FullName;Rel=('桌面角色管理/'+[IO.Path]::GetRelativePath($desktop,$f.FullName).Replace('\','/'));External=$true;Origin='桌面来源待核参考'}) } } foreach ($s in $sources) { $s | Add-Member Hash (Get-FileHash -LiteralPath $s.Full -Algorithm SHA256).Hash.ToLowerInvariant() } $groups = @($sources | Group-Object Hash) $generated = [Collections.Generic.List[object]]::new() $external = [Collections.Generic.List[object]]::new() $copyJobs = [Collections.Generic.List[object]]::new() function CleanName([string]$value) { $clean = [regex]::Replace($value,'[<>:"/\\|?*\x00-\x1f]','_') $clean = [regex]::Replace($clean,'\s+','_').Trim('.',' ') if ($clean.Length -gt 88) { $clean = $clean.Substring(0,88) } return $clean } function ShortText([string]$value,[int]$max=1200) { $value = ($value -replace '\r?\n',' ' -replace '\s+',' ').Trim() if ($value.Length -gt $max) { return $value.Substring(0,$max)+'…' } return $value } foreach ($group in $groups) { $ordered = @($group.Group | Sort-Object @{Expression={$_.External}},@{Expression={$_.Rel -match '^99_|post_frames|review|rejected'}},Rel) $s = $ordered[0]; $rel=$s.Rel; $f=Get-Item -LiteralPath $s.Full; $stem=$f.BaseName $isExternal = @($ordered | Where-Object {-not $_.External}).Count -eq 0 $category='生成辅助图'; $status='生产素材待审'; $use='构图、画面连续性与图生视频输入参考;入正式资产库前审看。' $prefix='02'; $title=$stem; $detail=''; $warn='图片存在不等于已通过艺术或连续性验收。'; $context=''; $shotId=''; $age='未在本图单独核定' $evidence=[Collections.Generic.List[string]]::new(); $evidence.Add($rel) $assetId=if ($stem -match '^(CHR|REF|BG|MAP)-') { $stem } else { '' } $isArchived=$rel.StartsWith('99_') if ($isExternal) { $category='外部参考';$status='非本项目生成/来源授权待核';$prefix='90' $title='外部参考|'+$stem $detail=if ($rel -match '画风参考|用户提供') {'用户提供的既有动画画风截图,仅作线条、色彩和光影讨论。'} else {'桌面角色管理目录中的原有参考图;未找到足够证据将其认定为本项目新生成的原创素材。'} $warn='不要按本项目原创生成图公开发布或出售;来源及授权待核实。';$use='私人参考索引,不默认上传。' } elseif ($rel -match 'post_frames/') { $category='视频逐帧中间图';$prefix='06';$status='后期中间图/非独立原画' $title=([regex]::Match($rel,'E01-S\d+-C\d+').Value)+'|'+$stem $detail='视频后期逐帧序列中的单帧,供屏幕内容合成、画面检查或后期追溯;相邻文件通常属于同一镜头,不应全部作为不同原创设定图。' $warn='帧序号是文件编号,不直接推算时间码;原视频帧率与提取起点须另查。';$use='技术归档;默认预览隐藏,不建议逐张公开上架。' } elseif ($stem -match '^(review|compare|waveform)|_sheet$|检查|抽帧|light_controlled_review' -or $rel -match '/scenes/') { $category=if ($stem -eq 'waveform') {'音频波形图'} else {'检查拼图或抽帧'} $prefix='05';$status='质检辅助/非独立原画' $title=if ($rel -match 'E01-S\d+-C\d+') { $Matches[0]+'|'+$stem } elseif($stem -eq 'waveform') { (Split-Path (Split-Path $s.Full) -Leaf)+'|波形图' } else {$stem} $detail='制作过程中生成的检查、比较或预览图片,用于画面节奏、构图或技术效果对照。' $warn='可能含多帧、标注或技术图表;不能当作单帧设定图或完整成片。';$use='内部审看与制作过程记录。' } if (-not $isExternal -and ($rel -match '/角色设定/' -or $stem -match '^CHR-|^REF-' -or $isArchived)) { $category='角色参考图';$prefix='01';$status='REVIEW/候选未定稿' if ($linked.ContainsKey($s.Full)) { $title=$linked[$s.Full] } elseif ($rel -match '/([^/]+)/(?:01_参考图|02_生成候选)/') { $title=$Matches[1]+'|'+$stem } $detail='用于角色身份、服装、种族轮廓和年龄版本对照的生成候选。' $warn='图中自发纹章、文字与装饰不自动成为小说设定;没有完整侧背、表情和跨镜测试。' $parent=Split-Path (Split-Path $s.Full) $design=Join-Path $parent '视觉设计方案.md' if (-not (Test-Path -LiteralPath $design) -and $rel -match '(0[12]_转生[前后]角色/.*)/(01_参考图|02_生成候选)/') { $design=Join-Path $ProjectRoot ('06_AI动漫制作/02_视觉圣经/角色设定/'+$Matches[1]+'/视觉设计方案.md') } if (Test-Path -LiteralPath $design) { $bullets=@(Get-Content -LiteralPath $design | Where-Object {$_ -match '^- (性别|视觉年龄|骨相|体型|服装|道具|动作|不可漂移|种族|发色|瞳色)'}) $bullets=@($bullets | Where-Object {$_ -notmatch '\d+岁|真实年龄'}) $context='通用角色卡供对照,旧版服装条目以本图生成记录覆盖:'+(ShortText ($bullets -join ' ') 1700) $evidence.Add([IO.Path]::GetRelativePath($ProjectRoot,$design).Replace('\','/')) } foreach ($k in @($reviewNotes.Keys | Sort-Object Length -Descending)) { if ($title -like "*$k*" -and $stem -match 'V08') { $detail += ' 生成记录的画面描述与初检:'+$reviewNotes[$k];$evidence.Add([IO.Path]::GetRelativePath($ProjectRoot,$reviewDoc).Replace('\','/'));break } } } foreach ($k in @($notes.Keys | Sort-Object Length -Descending)) { if (-not $isExternal -and $stem.Contains($k)) { $title=$notes[$k][0]+'|'+$stem;$detail=$notes[$k][1];$warn=$notes[$k][2];break } } if (-not $isExternal -and $stem -match '^BG-') { $category='场景设定图';$prefix='02';$status='REVIEW/候选未定稿' } if (-not $isExternal -and $stem -match '^MAP-') { $category='世界地图';$prefix='03';$status='REVIEW/布局草案';$use='地理布局、商路和行军关系讨论,不用于精确测距。' } if ($stem -match '(?:-|_)A(\d+)(?:-|_)') { $age=$Matches[1]+'岁(文件所标版本;归档图不保证符合现行时间线)' } if ($isArchived) { $prefix='04';$status='ARCHIVED/历史版本' $warn='已被后续方案替代或仍有问题,不作为当前生产底图。 '+$warn $use='设计迭代追溯、旧版对比;上传时必须保留“历史版本”标签。' if ($rel -match '服饰修订前') { $warn+=' 此批衣装与世界观时代、阶层要求不符,后续已经修订。' } if ($rel -match '魔王比例修订前') { $warn+=' 魔王头身比例或双视图比例有问题;当前参照V07而不是此图。' } if ($rel -match '魔王外貌微调前') { $warn+=' 此图早于V07头部缩小及衣装前片修订。' } if ($rel -match '地图候选') { $warn+=' 北部直连关系不足,当前已改为V03。' } if ($rel -match '角色细节修订前') { $warn+=' 这是V08B之前的局部问题版本;萨尔克靴/手、神父年龄或奈芙拉肢体读图需参照修订。' } } if ($rel -match '(E01-S\d+-C\d+)') { $shotId=$Matches[1] if ($shotMap.ContainsKey($shotId)) { $sh=$shotMap[$shotId];$sceneName=$sceneNames[[int]$sh.scene] $context="原镜头:$shotId;场景:$sceneName;景别机位:$($sh.framing);计划画面与表演:$($sh.action_performance);空间光线:$($sh.space_light);声音关系:$($sh.sound)。这是原制作说明,不保证每项均已在图片中正确实现。" $evidence.Add('06_AI动漫制作/05_生产素材/正片/S01E01_H3_V01/source_manifest.json') if ($category -eq '生成辅助图') { $title="$shotId|$sceneName|$stem";$detail="第一集对应镜头的生成首帧或返修底图。"+$sh.action_performance } } $readyPath=Join-Path (Split-Path $s.Full) 'ready.json' if (Test-Path -LiteralPath $readyPath) { $ready=Get-Content -LiteralPath $readyPath -Raw | ConvertFrom-Json if ($ready.frame_qc.notes) { $warn+=' 原首帧审看记录:'+$ready.frame_qc.notes } } } if ($rel -match 'rejected|first_v1') { $status='历史或返修前版本/复核';$warn+=' 文件位于返修或旧版路径,不按最新版自动使用。' } if (-not $detail) { $detail='本项目制作图片。独立画面内容尚无充分文字记录,请结合原图核对后补充;不从文件名编造人物身份或事件。' } if ($rel -match '视频测试' -and -not $shotId) { if ($rel -match '男主收手机') { $context='前世男主在化工厂更衣柜前收手机的测试段;母亲中秋消息通过屏幕文字表现,不另配母亲旁白。' } elseif ($rel -match '泉堂夜景|SC06_40秒') { $context='泉堂祝福测试段,855年的45岁马提亚斯、13岁阿黛尔与婴儿;冷窗光与暖烛光,神像保持静止与沉默。' } elseif ($rel -match '开篇40秒') { $context='前世租屋开篇40秒柔光测试,风扇、水杯、电脑和29岁男主的日常。测试版不自动替代正片同名镜头。' } $use+=' 测试素材与正式镜头身份分开登记。' } if ($rel -match '视频测试/.+/(?:frames/)?(C0[1-8])(?:R2)?(?:_|/|\.)' -and $rel -match 'SC06_40秒|开篇40秒') { $testId=$Matches[1] $testMatch=[regex]::Match($rel,'/(C0[1-8])(?:R2)?(?:_|/|\.)') $testId=$testMatch.Groups[1].Value if($rel -match 'SC06_40秒') { $testText=@{C01='马提亚斯怀抱婴儿来到泉堂神像前,阿黛尔持烛在旁,左窗冷光与右侧暖室构成静默空间。';C02='45岁神父怀抱婴儿祷告的近景,银白反光打断话语,眼神抬向神像方向。';C03='灰石神像水纹与婴儿祝福印记的近景,光是细微回应,不是神像活化或女神开口。';C04='13岁阿黛尔双手持烛的近景,视线由烛火转向画外神父与神像,表现好奇和探寻。';C05='神父等待回应的表情近景,抱婴姿势稳定,期待逐渐转为接受沉默。';C06='神父将目光重新落回婴儿、少女跟随注视的双人场面,祝福余光退去,回到实际照料。';C07='泉堂仪式之后的延续场面,以仍在燃烧的烛火和人物实际照料维持日常,没有女神额外发言。';C08='祝福事件后的记录与报告场面,供边境发现向大圣堂上报的段落衔接。'} } else { $testText=@{C01='租屋窗边风扇、半杯水与薄荷盆栽近景,冷暖柔光表现普通生活。';C02='前世男主从左肩后看电脑分类结果,屏幕中的猫是静态图片,鼠标与错误识别服务轻微幽默。';C03='29岁男主看向右侧电脑的侧面近景,克制笑意与自然疲倦构成普通青年形象。';C04='双手与笔记本的俯拍近景,未完成计算、铅笔和纸页用于呈现广泛但仍在试错的爱好。';C05='男主听见闹钟后停下电脑操作的侧面中景,收手和肩部放松表示结束折腾、准备休息。';C06='男主在窗边查看稀疏的第三盆薄荷,杯与土壤形成浇水动作起点,植物不神奇生长。';C07='浇水后拉窗帘、准备走向床铺的租屋全景,窗、家具和人物动线保持连续。'} } if($testText.ContainsKey($testId)) { if($category -eq '生成辅助图') {$detail=$testText[$testId]+' 这是该测试镜头的输入或辅助静图,动态完成度另查视频。';$title=$testId+'|'+$(if($rel -match 'SC06_40秒'){'泉堂祝福测试'}else{'前世租屋测试'})+'|'+$stem} else {$detail+=' 对应镜头内容:'+$testText[$testId]} } } $img=[Drawing.Image]::FromFile($s.Full) try { $width=$img.Width;$height=$img.Height;$format=$f.Extension.ToLowerInvariant() } finally { $img.Dispose() } $hash=$group.Name $imageName=if($existingNames.ContainsKey($hash)) {$existingNames[$hash]} else {(CleanName ("$prefix"+'__'+$title))+'__'+$hash.Substring(0,10)+$format} $folder=if ($isExternal) {'外部参考_不默认上传'} else {'图片'} $outRel="$folder/$imageName" $srcRels=@($ordered.Rel) $description="《炉火照彻诸神沉默的年代》素材。$detail "+$(if($context){"设定/制作背景(非逐项视觉验收):$context "})+"状态:$status。用途:$use 注意:$warn" $record=[pscustomobject][ordered]@{ asset_id=('IMG-'+$hash.Substring(0,16));original_asset_id=$assetId;filename=$imageName;image_path=$outRel; title=$title;category=$category;status=$status;description=$description;content_description=$detail; context_notes=$context;usage=$use;limitations=$warn;age_version=$age;shot_id=$shotId; tags=@('炉火照彻诸神沉默的年代','AI动漫',$category,$status);width=$width;height=$height;bytes=$f.Length; sha256=$hash;original_filename=$f.Name;source_paths=$srcRels;evidence_paths=@($evidence); description_basis='依据项目索引、已有生成记录及制作数据整理;不是全图重新逐张验收'; public_release_approved=$false;rights_note='本包不作版权或商业授权保证;外部参考与生成素材分别标记'; default_gallery_visible=($prefix -in @('01','02','03'));duplicate_source_count=$srcRels.Count } $copyJobs.Add([pscustomobject]@{Source=$s.Full;Relative=$outRel;Record=$record}) if ($isExternal) { $external.Add($record) } else { $generated.Add($record) } } $generated=@($generated | Sort-Object category,title,asset_id) $external=@($external | Sort-Object title) foreach($folder in @("$dest/图片","$dest/外部参考_不默认上传")) {if(-not(Test-Path -LiteralPath $folder)){New-Item -ItemType Directory -Path $folder | Out-Null}} $utf8=[Text.UTF8Encoding]::new($false) foreach ($job in $copyJobs) { $target=Join-Path $dest $job.Relative if(Test-Path -LiteralPath $target){ if((Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant() -ne $job.Record.sha256){throw "图片内容已改变,不覆盖:$target"} }else{Copy-Item -LiteralPath $job.Source -Destination $target} $r=$job.Record $text=@" 标题:$($r.title) 素材编号:$($r.asset_id) 原资产编号:$($r.original_asset_id) 图片文件:$($r.filename) 分类:$($r.category) 状态:$($r.status) 年龄版本:$($r.age_version) 镜头编号:$($r.shot_id) 【内容说明】 $($r.content_description) 【设定与制作背景】 $($r.context_notes) 【建议用途】 $($r.usage) 【版本问题与使用限制】 $($r.limitations) 【尺寸与来源】 尺寸:$($r.width) × $($r.height) 像素 文件字节数:$($r.bytes) SHA-256:$($r.sha256) 原文件名:$($r.original_filename) 全部来源: $($r.source_paths -join [Environment]::NewLine) 说明依据:$($r.description_basis) 公开发布批准:尚未确认;本包仅供整理和选择上传 来源授权说明:$($r.rights_note) 【素材网站可用简介】 $($r.description) 标签:$($r.tags -join ',') "@ [IO.File]::WriteAllText([IO.Path]::ChangeExtension($target,'.txt'),$text,$utf8) } $catalog=[pscustomobject]@{schema_version=1;project='炉火照彻诸神沉默的年代';packaged_date=$PackageDate;source_image_files=$sources.Count;project_source_files=$paths.Count;unique_project_images=$generated.Count;unique_external_references=$external.Count;duplicate_copies_removed=$sources.Count-$groups.Count;items=$generated} [IO.File]::WriteAllText("$dest/素材清单.json",($catalog | ConvertTo-Json -Depth 12),$utf8) [IO.File]::WriteAllText("$dest/外部参考清单.json",(@($external) | ConvertTo-Json -Depth 12),$utf8) $csv=@($generated | ForEach-Object { [pscustomobject][ordered]@{素材编号=$_.asset_id;文件名=$_.filename;图片路径=$_.image_path;标题=$_.title;分类=$_.category;版本状态=$_.status;详细说明=$_.description;标签=($_.tags -join ';');宽度=$_.width;高度=$_.height;字节数=$_.bytes;原资产编号=$_.original_asset_id;年龄版本=$_.age_version;镜头编号=$_.shot_id;原路径=($_.source_paths -join ';');SHA256=$_.sha256;公开发布已批准='否';说明依据=$_.description_basis} }) $csv | Export-Csv -LiteralPath "$dest/素材清单.csv" -NoTypeInformation -Encoding utf8BOM $generated | Where-Object default_gallery_visible | Select-Object asset_id,filename,title,category,status | Export-Csv -LiteralPath "$dest/优先审看清单.csv" -NoTypeInformation -Encoding utf8BOM $sourceMap=foreach($r in @($generated)+@($external)) {foreach($p in $r.source_paths){[pscustomobject]@{原路径=$p;目标路径=$r.image_path;SHA256=$r.sha256;处理='原文件保留;内容相同仅输出一份'}}} $sourceMap | Export-Csv -LiteralPath "$dest/原图到上传文件映射.csv" -NoTypeInformation -Encoding utf8BOM $escapedJson=(@($generated) | ConvertTo-Json -Depth 10 -Compress).Replace('<','\u003c') $template=[IO.File]::ReadAllText((Join-Path $PSScriptRoot 'image_catalog_gallery.html')) [IO.File]::WriteAllText("$dest/图片预览目录.html",$template.Replace('__CATALOG_DATA__',$escapedJson),$utf8) $counts=($generated | Group-Object category | ForEach-Object {"- $($_.Name):$($_.Count)张"}) -join [Environment]::NewLine $readme=@" # 素材网上传包 项目:《炉火照彻诸神沉默的年代》;整理日期:$PackageDate。 ## 直接使用 - **图片/**:$($generated.Count)张内容去重后的本项目图片全部集中在这一个文件夹;每张旁边有同名TXT详细说明。 - **图片预览目录.html**:双击可离线打开,按标题/说明/分类搜索;默认只显示当前人物、地图与生产底图,勾选可显示历史和中间图。 - **素材清单.csv**:UTF-8 BOM,通用网站批量导入字段;用表格软件打开时选UTF-8。 - **素材清单.json**:包含图片、标题、完整说明、标签、尺寸、来源、版本、SHA-256。 - **优先审看清单.csv**:便于先审核候选原画,不代表已批准公开发布。 - **原图到上传文件映射.csv**:原路径到唯一图片的映射,不遗漏重复副本来源。 - **外部参考_不默认上传/**:$($external.Count)张外部截图或来源未核的桌面参考,另附说明,不混入生成图库。 ## 盘点与范围 扫描仓库已有图片$($paths.Count)个,加已知桌面角色参考$($sources.Count-$paths.Count)个。以文件内容SHA-256去重;相同画面但字节不同的图片仍分别保留,不做相似图删除。 合计保留$($groups.Count)份唯一图片,其中本项目$($generated.Count)张、外部参考$($external.Count)张;去掉$($sources.Count-$groups.Count)个重复副本的额外拷贝,原文件全部不动。仅整理本地落盘项目文件,未声称扫描整台电脑、所有临时生成目录或服务器全部图片。 没有重画、裁切、压缩或放大图片,目标图片与来源字节一致;没有上传网站、提交或推送。 ## 本项目图片分类 $counts ## 上传前注意 1. 当前人设仍为REVIEW,不因整理自动成为定稿。旧版图保留ARCHIVED;同名人物的年龄版不能混用。 2. 说明来自原生成记录、项目设定与镜头资料;没有声称重新逐张通过视觉质检。设定背景是目标,不保证画面正确画出所有细节。 3. 逐帧图、波形、拼图和检查图只宜技术归档,不能包装成独立原创设定图。网站可用分类过滤。 4. 外部动画截图和桌面来源待核参考不进入主导入表。生成素材的具体授权仍需按你的实际服务与用途确认,本包不宣称授权已经审核。 5. 不上传工作流、服务地址、凭据或服务器配置;图片保留原字节,PNG可能仍有原生元数据,公开发布前按素材站隐私要求检查。 6. CSV为通用字段,并非已适配某个站点接口;导入时映射“图片路径、标题、详细说明、标签”。TXT可直接复制文案。 "@ [IO.File]::WriteAllText("$dest/README.md",$readme,$utf8) $failures=[Collections.Generic.List[string]]::new() foreach($job in $copyJobs){ $target=Join-Path $dest $job.Relative if ((Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant() -ne $job.Record.sha256) {$failures.Add($job.Relative)} if (-not (Test-Path -LiteralPath ([IO.Path]::ChangeExtension($target,'.txt')))) {$failures.Add('missing description '+$job.Relative)} } $validation=[pscustomobject]@{image_files=$groups.Count;main_images=$generated.Count;external_images=$external.Count;descriptions=$groups.Count;csv_rows=@(Import-Csv -LiteralPath "$dest/素材清单.csv").Count;source_mapping_rows=@($sourceMap).Count;hash_mismatches=@($failures);source_files_untouched=$true} [IO.File]::WriteAllText("$dest/校验报告.json",($validation | ConvertTo-Json -Depth 5),$utf8) [pscustomobject]@{destination=$dest;validation=$validation;categories=@($generated | Group-Object category | Select-Object Count,Name)} | ConvertTo-Json -Depth 6